From 11930868dd36cbe055768d15dc6da01d45a983c3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:11:54 +0900 Subject: [PATCH 1/8] torch_c builds for CPython 3.15t and keeps the GIL until the audit (#51) abi3 becomes a default cargo feature; --no-default-features builds a version-pinned _C against a free-threaded interpreter. pyo3 0.29 declares a module GIL-free by default, and the first 3.15t build read sys._is_gil_enabled() == False before any global was audited, so the module now says gil_used = true. tests/api/test_freethreaded.py checks both in a 3.15t subprocess; with the attribute removed it goes red. --- .github/scripts/gate_suites.py | 1 + tests/api/test_freethreaded.py | 77 +++++++++++++++++++++++++++++ torchnative/rust/torch_c/Cargo.toml | 10 +++- torchnative/rust/torch_c/src/lib.rs | 7 ++- 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_freethreaded.py diff --git a/.github/scripts/gate_suites.py b/.github/scripts/gate_suites.py index 105afdd9..676af556 100644 --- a/.github/scripts/gate_suites.py +++ b/.github/scripts/gate_suites.py @@ -82,6 +82,7 @@ #: Every gate suite, and the runners it runs on. `()` means local-only. SUITES = { "_support/test_shim.py": BOTH, + "api/test_freethreaded.py": BOTH, "api/test_import.py": BOTH, "api/test_tnnamespace.py": BOTH, "api/test_tntransformers.py": BOTH, diff --git a/tests/api/test_freethreaded.py b/tests/api/test_freethreaded.py new file mode 100644 index 00000000..00477bb1 --- /dev/null +++ b/tests/api/test_freethreaded.py @@ -0,0 +1,77 @@ +"""`torch._C` on a free-threaded interpreter (CPython 3.15t, issue #51). + +The abi3 wheel cannot load into 3.15t, so the extension also builds without +abi3 (`cargo build --no-default-features` against that interpreter). This suite +loads that build in a 3.15t subprocess and checks two things: + + * **It is the shim and it computes.** `_aten_implemented` is present, and + `torch.ones(2) + 1` gives upstream's answer. + * **It keeps the GIL.** pyo3 0.29 declares a module GIL-free unless told + otherwise. The first 3.15t build took it at its word, and + `sys._is_gil_enabled()` read False, before any of the shim's globals had + been audited for free threading. `lib.rs` now asks for the GIL + (`gil_used = true`), so the interpreter re-enables it on import and warns. + When the audit and its thread tests land, this test flips with them. + +Inputs, both required or every test skips by name: + TORCHNATIVE_FT_PYTHON a 3.15t interpreter (with numpy, sympy, typing_extensions) + TORCHNATIVE_FT_STAGE a vendored tree whose `torch/` holds the 3.15t `_C` + +What this cannot see: thread safety. One thread imports and adds; nothing here +races the globals. +""" + +import os +import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_support")) +import _skip # noqa: E402 + +_PROBE = r""" +import json, sys, warnings +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + import torch +print(json.dumps({ + "ft_build": bool(__import__("sysconfig").get_config_var("Py_GIL_DISABLED")), + "shim": hasattr(torch._C, "_aten_implemented"), + "gil": sys._is_gil_enabled(), + "warned": [str(w.message) for w in caught if "GIL" in str(w.message)], + "value": (torch.ones(2) + 1).tolist(), +})) +""" + + +def _probe(): + py = os.environ.get("TORCHNATIVE_FT_PYTHON") + stage = os.environ.get("TORCHNATIVE_FT_STAGE") + if not py or not stage: + raise _skip.Skip("TORCHNATIVE_FT_PYTHON / TORCHNATIVE_FT_STAGE not set (issue #51)") + env = dict(os.environ, PYTHONPATH=stage, TORCH_USE_RTLD_GLOBAL="1") + env.pop("PYTHON_GIL", None) + proc = subprocess.run([py, "-c", _PROBE], capture_output=True, text=True, env=env, timeout=300) + if proc.returncode != 0: + raise RuntimeError(f"3.15t probe exited {proc.returncode}\n{proc.stderr[-2000:]}") + import json + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_ft_interpreter_loads_the_shim_and_computes(): + r = _probe() + assert r["ft_build"], "TORCHNATIVE_FT_PYTHON is not a free-threaded build" + assert r["shim"], "imported upstream torch, not the shim" + assert r["value"] == [2.0, 2.0], r["value"] + + +def test_module_keeps_the_gil_until_the_audit_lands(): + r = _probe() + assert r["gil"] is True, "torch._C declared itself GIL-free before the #51 audit" + assert any("torch._C" in w for w in r["warned"]), r["warned"] + + +if __name__ == "__main__": + raise SystemExit(_skip.run_tests( + [(n, f) for n, f in list(globals().items()) if n.startswith("test_") and callable(f)], + "api/test_freethreaded", + )) diff --git a/torchnative/rust/torch_c/Cargo.toml b/torchnative/rust/torch_c/Cargo.toml index 1cc5732b..c3a919a0 100644 --- a/torchnative/rust/torch_c/Cargo.toml +++ b/torchnative/rust/torch_c/Cargo.toml @@ -9,6 +9,13 @@ edition = "2021" name = "_C" crate-type = ["cdylib"] +[features] +# abi3 is the default wheel (cp313-abi3). A free-threaded interpreter (3.15t) +# cannot load abi3, so its wheel builds with `--no-default-features` against +# that interpreter and is tagged cp315-cp315t (issue #51). +default = ["abi3"] +abi3 = ["pyo3/abi3-py313"] + [dependencies] # `abi3-py313` is ABI3.md §7's recommendation, and the floor is exactly 3.13: # lowering it gives up the 3.13 stable-ABI replacements (`PyObject_GetOptionalAttr` @@ -20,7 +27,8 @@ crate-type = ["cdylib"] # It also removes a silent failure mode: a version-pinned `_C.so` carries no tag # and loads into an interpreter it was not built for (§5d). The platform is # CPython 3.14.7, not the 3.13 this crate had been compiled against. -pyo3 = { version = "0.29.2", features = ["extension-module", "abi3-py313"] } +pyo3 = { version = "0.29.2", features = ["extension-module"] } + # The tensor engine (DESIGN.md §4). `candle-core` only -- `candle-transformers` # re-implements model architectures in Rust, which is the approach §1 rejects. diff --git a/torchnative/rust/torch_c/src/lib.rs b/torchnative/rust/torch_c/src/lib.rs index 258380c7..d38d8b74 100644 --- a/torchnative/rust/torch_c/src/lib.rs +++ b/torchnative/rust/torch_c/src/lib.rs @@ -680,7 +680,12 @@ fn apply_gemm_threading_threshold() { gemm::set_threading_threshold(value); } -#[pymodule] +// The shim's globals (RNG streams, device counters, the tape, caches, CoreML and +// Metal state) have not been audited for free threading (issue #51). pyo3 0.29 +// declares a module GIL-free unless told otherwise, and a 3.15t interpreter took +// it at its word (`sys._is_gil_enabled()` read False). Until the audit and its +// thread tests land, the module asks for the GIL, so 3.15t re-enables it on import. +#[pymodule(gil_used = true)] fn _C(m: &Bound<'_, PyModule>) -> PyResult<()> { apply_gemm_threading_threshold(); flash::apply_env(); From 5e048892ce313b4e0de446481b5e0b9cb150fd9b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:05:23 +0900 Subject: [PATCH 2/8] Free-threaded cp315t wheels for host and Linux (#51 step 2) build.py --abi ft builds _C with --no-default-features against a 3.15t target interpreter and tags the wheel from its sysconfig (ftabi.py). Windows, iOS, Android and wasm refuse by name: no free-threaded target CPython, or a Windows path written against python313. publish-pypi gains a build-ft job (host, linux-x86_64, linux-aarch64) on PBS 3.15.0rc1 freethreaded; collect expects those three wheels by name. test_ftwheel 25 ok; GIL-check and tag mutants go red. --- .github/scripts/gate_suites.py | 1 + .github/workflows/publish-pypi.yml | 149 ++++++++++++++- scripts/wheel/build.py | 92 +++++++++- scripts/wheel/ftabi.py | 129 +++++++++++++ tests/release/test_ftwheel.py | 280 +++++++++++++++++++++++++++++ 5 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 scripts/wheel/ftabi.py create mode 100644 tests/release/test_ftwheel.py diff --git a/.github/scripts/gate_suites.py b/.github/scripts/gate_suites.py index 676af556..3d8e3628 100644 --- a/.github/scripts/gate_suites.py +++ b/.github/scripts/gate_suites.py @@ -194,6 +194,7 @@ "release/test_cigate.py": BOTH, "release/test_cipub.py": BOTH, "release/test_docrefs.py": BOTH, + "release/test_ftwheel.py": BOTH, "release/test_layout.py": BOTH, "release/test_platver_harnesses.py": BOTH, "release/test_publish.py": BOTH, diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3f1addd7..f8426355 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -71,6 +71,9 @@ env: # from, checksummed in docs/platform/TARGET_PYTHON.md §3 and §4. PBS_RELEASE: "20260825" PBS_PYTHON: "3.13.15" + # The free-threaded interpreter the cp315-cp315t wheels are built against + # (issue #51). PBS_RELEASE ships its `-freethreaded-install_only` archives. + PBS_FT_PYTHON: "3.15.0rc1" PYTHONUNBUFFERED: "1" jobs: @@ -594,9 +597,123 @@ jobs: # # "Nine wheels or no upload", made mechanical. This job is the only thing # between a partial matrix and PyPI. + # The free-threaded wheels (issue #51): cp315-cp315t, beside the nine abi3 + # ones. A separate job so `build`'s matrix stays exactly the nine abi3 + # targets. Only the targets scripts/wheel/ftabi.py can derive are built; + # every other target is refused there by name (Windows, iOS, Android, wasm). + # Not built here: anything that proves the wheel loads on a 3.15t interpreter. + build-ft: + needs: [preflight, vendor] + strategy: + fail-fast: false + matrix: + include: + - target: host + runner: macos-14 + triple: aarch64-apple-darwin + - target: linux-x86_64 + runner: ubuntu-24.04 + triple: x86_64-unknown-linux-gnu + - target: linux-aarch64 + runner: ubuntu-24.04 + triple: aarch64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + # The abi3 host shim (build.py byte-compares it) and the ft artefacts + # must not share a cargo target directory. + CARGO_TARGET_DIR: ${{ github.workspace }}/_cargo + TORCHNATIVE_FT_CARGO_TARGET_DIR: ${{ github.workspace }}/_cargo-ft + TORCHNATIVE_TARGET_PYTHON: ${{ github.workspace }}/_target-python + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/download-artifact@v4 + with: + name: vendored-tree + + - name: unpack the vendored tree + run: tar -C torchnative/python -xzf vendored-tree.tgz + + - name: host shim + run: bash scripts/vendor/install_shim.sh + + - name: build dependencies + run: python -m pip install --upgrade setuptools wheel packaging build + + - name: target CPython 3.15t (python-build-standalone, freethreaded) + run: | + set -eu + mkdir -p _target-python _dl + triple="${{ matrix.triple }}" + url="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_FT_PYTHON}+${PBS_RELEASE}-${triple}-freethreaded-install_only.tar.gz" + curl -sSfL "$url" -o "_dl/${triple}.tar.gz" + rm -rf _dl/python + tar -C _dl -xzf "_dl/${triple}.tar.gz" + mv _dl/python "_target-python/${triple}-freethreaded" + test -x "_target-python/${triple}-freethreaded/bin/python3.15t" + + - name: rust target + run: | + case "${{ matrix.target }}" in + host) exit 0 ;; + linux-x86_64) rustup target add x86_64-unknown-linux-gnu ;; + linux-aarch64) rustup target add aarch64-unknown-linux-gnu ;; + esac + + - name: cargo-zigbuild and ziglang + if: startsWith(matrix.target, 'linux-') + run: | + python -m pip install ziglang + cargo install cargo-zigbuild --locked + + - name: build _C without abi3 + working-directory: torchnative/rust/torch_c + env: + TP: ${{ github.workspace }}/_target-python + run: | + set -eux + export CARGO_TARGET_DIR="$TORCHNATIVE_FT_CARGO_TARGET_DIR" + case "${{ matrix.target }}" in + host) + PYO3_PYTHON="$TP/${{ matrix.triple }}-freethreaded/bin/python3.15t" \ + cargo build --release --no-default-features ;; + linux-x86_64|linux-aarch64) + PYO3_CROSS=1 PYO3_CROSS_PYTHON_VERSION=3.15 \ + PYO3_CROSS_LIB_DIR="$TP/${{ matrix.triple }}-freethreaded/lib" \ + cargo zigbuild --release --no-default-features \ + --target "${{ matrix.triple }}.2.17" ;; + esac + + - name: build the wheel + run: | + rm -rf build + if [ "${{ matrix.target }}" = "host" ]; then + python scripts/wheel/build.py --abi ft \ + --ft-python "$TORCHNATIVE_TARGET_PYTHON/${{ matrix.triple }}-freethreaded/bin/python3.15t" + else + python scripts/wheel/build.py --abi ft --target "${{ matrix.target }}" + fi + ls -l dist/ + + - uses: actions/upload-artifact@v4 + with: + name: wheel-ft-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error + retention-days: 7 + collect: runs-on: ubuntu-24.04 - needs: [preflight, gate, build] + needs: [preflight, gate, build, build-ft] env: # verify_cross.py resolves each wheel's symbols against the interpreter # it is for, so collect needs the same target CPythons the builds used. @@ -641,7 +758,9 @@ jobs: "win_arm64", "pyemscripten_2026_0_wasm32", )} - have = {p.name for p in pathlib.Path("_wheels").glob("*.whl")} + # The free-threaded wheels (issue #51) are named separately below. + have = {p.name for p in pathlib.Path("_wheels").glob("*.whl") + if "-cp315-cp315t-" not in p.name} missing, extra = sorted(want - have), sorted(have - want) for m in missing: print(f"::error::missing wheel {m}") @@ -652,6 +771,29 @@ jobs: print(f"ok all nine wheels present for {v}") EOF + - name: exactly these three free-threaded wheels, by name + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + python - <<'EOF' + import os, pathlib, sys + v = os.environ["VERSION"] + want = { + f"torchnative-{v}-cp315-cp315t-macosx_11_0_arm64.whl", + f"torchnative-{v}-cp315-cp315t-manylinux_2_17_x86_64.whl", + f"torchnative-{v}-cp315-cp315t-manylinux_2_17_aarch64.whl", + } + have = {p.name for p in pathlib.Path("_wheels").glob("*-cp315-cp315t-*.whl")} + missing, extra = sorted(want - have), sorted(have - want) + for m in missing: + print(f"::error::missing free-threaded wheel {m}") + for e in extra: + print(f"::error::unexpected free-threaded wheel {e}") + if missing or extra: + sys.exit(1) + print(f"ok all three free-threaded wheels present for {v}") + EOF + # twine's own metadata check, on every wheel. A wheel that twine refuses # is refused here rather than half-way through the upload, where the # earlier files are already live and cannot be replaced. @@ -690,6 +832,9 @@ jobs: rc=0 for w in _wheels/*.whl; do case "$w" in *macosx*) continue ;; esac + # free-threaded wheels carry their own tag and extension name, so + # the abi3 reference comparison does not apply to them + case "$w" in *-cp315-cp315t-*) continue ;; esac python scripts/wheel/verify_cross.py "$w" --reference "$ref" || rc=1 done exit $rc diff --git a/scripts/wheel/build.py b/scripts/wheel/build.py index e75c1820..473dcd4c 100644 --- a/scripts/wheel/build.py +++ b/scripts/wheel/build.py @@ -117,6 +117,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import ftabi # noqa: E402 from binfmt import (describe, elf_dynamic, elf_info, macho_arches, # noqa: E402 pe_imports, pe_info, macho_info, wasm_info) @@ -2062,7 +2063,8 @@ def check_registry() -> None: def _repack(wheel: Path, extra: dict[str, bytes], dist_info: str, plat: str | None = None, overrides: dict[str, bytes] | None = None, - renames: dict[str, str] | None = None) -> Path: + renames: dict[str, str] | None = None, + ft: "ftabi.FtIdentity | None" = None) -> Path: """Rewrite the archive with `extra` added and RECORD regenerated. zipfile cannot delete or replace a member, so the whole archive is rebuilt. @@ -2126,6 +2128,15 @@ def _repack(wheel: Path, extra: dict[str, bytes], dist_info: str, item = zipfile.ZipInfo(name, date_time=item.date_time) item.external_attr = 0o755 << 16 item.compress_type = zipfile.ZIP_DEFLATED + elif item.filename == wheel_name and ft is not None: + if new_plat: + data = ftabi.ft_wheel_metadata( + b"".join( + (line.rsplit(b"-", 1)[0] + b"-" + new_plat.encode() + b"\n") + if line.startswith(b"Tag: ") else line + b"\n" + for line in data.splitlines()), ft) + else: + data = ftabi.ft_wheel_metadata(data, ft) elif item.filename == wheel_name and new_plat: # `Tag:` in WHEEL and the filename have to agree; installers read # the filename, `wheel unpack` and auditors read this. @@ -2154,6 +2165,8 @@ def _repack(wheel: Path, extra: dict[str, bytes], dist_info: str, final = wheel if new_plat is None else wheel.with_name( _retag(wheel.name, new_plat) ) + if ft is not None: + final = final.with_name(ftabi.ft_wheel_name(final.name, ft)) tmp.replace(final) if final != wheel: wheel.unlink() @@ -2323,9 +2336,13 @@ def upstream_dist_info(version: str) -> dict[str, bytes]: def verify(wheel: Path, expected: set[str], target: "Target | None", - extra: dict[str, bytes], dist_info: str) -> None: + extra: dict[str, bytes], dist_info: str, + ft: "ftabi.FtIdentity | None" = None) -> None: with zipfile.ZipFile(wheel) as zf: names = set(zf.namelist()) + if ft is not None: + expected = {ft.member if n == Target.extension_member else n + for n in expected} if target is not None and target.extension_member != Target.extension_member: # The source tree always holds the host shim under the POSIX name; # this target's wheel holds the same slot under another one. Rewrite @@ -2347,7 +2364,10 @@ def verify(wheel: Path, expected: set[str], target: "Target | None", if "-none-any" in wheel.name: _fail(f"{wheel.name} is tagged py3-none-any -- setup.py's " "BinaryDistribution did not take effect") - if "-abi3-" not in wheel.name: + if ft is not None: + if f"-{ft.tag_prefix}-" not in wheel.name or "-abi3-" in wheel.name: + _fail(f"{wheel.name} is not tagged {ft.tag_prefix}") + elif "-abi3-" not in wheel.name: _fail(f"{wheel.name} is not abi3-tagged -- the bdist_wheel " "py_limited_api option did not take effect") @@ -3196,6 +3216,34 @@ def self_test_pyemscripten() -> int: return bad +#: Where the `--no-default-features` cargo build writes. Separate from +#: CARGO_TARGET_DIR because `check_host_shim` byte-compares the abi3 shim there. +FT_CARGO_TARGET_DIR = Path(os.environ.get( + "TORCHNATIVE_FT_CARGO_TARGET_DIR", REPO / "torchnative" / "rust" / "torch_c" / "target-ft")) + + +def _ft_python_root(target: "Target") -> Path: + return TARGET_PYTHON_ROOT / f"{target.rust_target}-freethreaded" + + +def _resolve_ft(args, target: "Target | None") -> "ftabi.FtIdentity": + """`--abi ft`: refuse by name, or derive the identity from the 3.15t interpreter.""" + if args.target in ftabi.FT_REFUSALS: + _fail(f"--abi ft --target {args.target}: {ftabi.FT_REFUSALS[args.target]}\n" + " (a free-threaded wheel is a different artefact from the " + "cp313-abi3 one, and none exists here)") + try: + if target is None: + py = args.ft_python or os.environ.get("PYO3_PYTHON") + if not py: + _fail("--abi ft on the host needs a 3.15t interpreter: pass " + "--ft-python PATH (or set PYO3_PYTHON)") + return ftabi.ft_identity(ftabi.interpreter_variables(py)) + return ftabi.ft_identity(target_sysconfig(_ft_python_root(target))) + except ftabi.FtRefusal as exc: + _fail(f"--abi ft: {exc}") + + def main() -> None: check_registry() ap = argparse.ArgumentParser(description=__doc__) @@ -3205,6 +3253,13 @@ def main() -> None: ap.add_argument("--target", default="host", choices=["host", *sorted(TARGETS)], help="cross target; default is this machine") + ap.add_argument("--abi", default="abi3", choices=["abi3", "ft"], + help="abi3 (default): the cp313-abi3 wheel. ft: the " + "free-threaded cp315-cp315t wheel (issue #51), built " + "without abi3 against a 3.15t interpreter") + ap.add_argument("--ft-python", default=None, + help="--abi ft, host: the 3.15t interpreter " + "(falls back to PYO3_PYTHON)") ap.add_argument("--self-test", action="store_true", help="exercise the artefact freshness check and exit; " "builds nothing") @@ -3229,6 +3284,10 @@ def main() -> None: target = None if args.target == "host" else TARGETS[args.target] + ft_ident = None + if args.abi == "ft": + ft_ident = _resolve_ft(args, target) + # Before anything else this target might need, including the vendored tree # and the host shim: a refusal is about the *machine*, and none of those # would change it. Answering with the reason here is what "refuses by name" @@ -3256,6 +3315,21 @@ def main() -> None: overrides: dict[str, bytes] = {} plat: str | None = None + if ft_ident is not None: + if target is not None: + target.artefact = FT_CARGO_TARGET_DIR / target.rust_target / "release" / target.artefact.name + target.python_root = _ft_python_root(target) + else: + host_art = next((FT_CARGO_TARGET_DIR / "release" / n + for n in ("lib_C.dylib", "lib_C.so") + if (FT_CARGO_TARGET_DIR / "release" / n).exists()), None) + if host_art is None: + _fail(f"no ft host extension under {FT_CARGO_TARGET_DIR}/release.\n" + " Fix: PYO3_PYTHON=<3.15t python> CARGO_TARGET_DIR=" + f"{FT_CARGO_TARGET_DIR} cargo build --release " + "--no-default-features (in torchnative/rust/torch_c)") + require_current(host_art, "the --no-default-features cargo build above") + overrides["torch/_C.abi3.so"] = host_art.read_bytes() if target is not None: if not target.artefact.exists(): # `rebuild_hint` rather than a list of every target's build command: @@ -3280,7 +3354,11 @@ def main() -> None: plat = target.platform_tag(cross) overrides["torch/_C.abi3.so"] = cross renames: dict[str, str] = {} - if target is not None and target.extension_member != Target.extension_member: + if ft_ident is not None: + renames["torch/_C.abi3.so"] = ft_ident.member + print(f" member: torch/_C.abi3.so -> {ft_ident.member} " + f"(free-threaded, tag {ft_ident.tag_prefix})") + elif target is not None and target.extension_member != Target.extension_member: renames["torch/_C.abi3.so"] = target.extension_member print(f" member: torch/_C.abi3.so -> {target.extension_member} " "(this interpreter's dynload table\n" @@ -3312,13 +3390,15 @@ def main() -> None: version = wheel.name.split("-")[1] extra = {**upstream_dist_info(version), **global_deps_stub(target)} wheel = _repack(wheel, extra, f"torchnative-{version}.dist-info", - plat=plat, overrides=overrides, renames=renames) + plat=plat, overrides=overrides, renames=renames, + ft=ft_ident) expected: set[str] = set() for pkg in ("torch", *stamp.get("packages", "").split(","), "torchnative"): if pkg and (SRC / pkg).is_dir(): expected |= tree_files(SRC / pkg) - verify(wheel, expected, target, extra, f"torchnative-{version}.dist-info") + verify(wheel, expected, target, extra, f"torchnative-{version}.dist-info", + ft=ft_ident) final_wheel = args.outdir / wheel.name shutil.move(str(wheel), str(final_wheel)) diff --git a/scripts/wheel/ftabi.py b/scripts/wheel/ftabi.py new file mode 100644 index 00000000..6f9a31a3 --- /dev/null +++ b/scripts/wheel/ftabi.py @@ -0,0 +1,129 @@ +"""The free-threaded wheel variant (issue #51): what is derived, and what is refused. + +Pure and importable without building anything. `build.py --abi ft` calls it; so +does `tests/release/test_ftwheel.py`. + +A free-threaded CPython (3.15t) has no stable ABI, so the abi3 wheel cannot load +there. The ft wheel carries a version-pinned extension name and a `cpXYZ-cpXYZt` +tag. Everything version-shaped is read out of the target interpreter's +sysconfig (`Py_GIL_DISABLED`, `VERSION`, `SOABI`, `EXT_SUFFIX`) and cross-checked, +so a GIL interpreter, or one whose suffix disagrees with its version, is refused +by name instead of producing a wheel that fails at import. + +What this cannot see: whether the produced wheel loads. That needs a 3.15t +interpreter and is `tests/*/test_freethreaded.py`'s subprocess. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass + + +class FtRefusal(Exception): + """The free-threaded variant cannot be built or derived here; the text says why.""" + + +@dataclass(frozen=True) +class FtIdentity: + python_tag: str # cp315 + abi_tag: str # cp315t + ext_suffix: str # .cpython-315t-x86_64-linux-gnu.so + member: str # torch/_C + + @property + def tag_prefix(self) -> str: + return f"{self.python_tag}-{self.abi_tag}" + + +_VARS = ("Py_GIL_DISABLED", "VERSION", "SOABI", "EXT_SUFFIX") + + +def ft_identity(variables: dict) -> FtIdentity: + """Derive the ft tag and extension name from an interpreter's sysconfig vars.""" + flag = variables.get("Py_GIL_DISABLED") + if flag is None: + raise FtRefusal( + "Py_GIL_DISABLED is not set in this interpreter's sysconfig; it is " + "not known to be a free-threaded build, and neither true nor false " + "is assumed") + try: + free = int(flag) == 1 + except (TypeError, ValueError): + raise FtRefusal(f"Py_GIL_DISABLED is {flag!r}, which is not 0 or 1") from None + if not free: + raise FtRefusal( + "this interpreter is not free-threaded (Py_GIL_DISABLED is " + f"{flag!r}); an extension built for it is not a cp3XXt wheel") + m = re.fullmatch(r"3\.(\d+)", str(variables.get("VERSION", ""))) + if not m: + raise FtRefusal(f"VERSION is {variables.get('VERSION')!r}, expected 3.") + minor = m.group(1) + want = f"3{minor}t" + for key in ("SOABI", "EXT_SUFFIX"): + value = variables.get(key) + if not isinstance(value, str) or want not in value: + raise FtRefusal( + f"{key} is {value!r}, which does not carry {want}; VERSION " + f"{variables.get('VERSION')} with Py_GIL_DISABLED=1 implies it, " + "so the interpreter's sysconfig disagrees with itself") + suffix = variables["EXT_SUFFIX"] + return FtIdentity(f"cp3{minor}", f"cp3{minor}t", suffix, "torch/_C" + suffix) + + +def ft_wheel_name(name: str, ident: FtIdentity) -> str: + """Swap the python and abi tags of `----.whl`.""" + if not name.endswith(".whl"): + raise FtRefusal(f"{name} is not a wheel filename") + parts = name[:-4].split("-") + if len(parts) != 5: + raise FtRefusal( + f"{name} has {len(parts)} dash-separated parts, not the five of " + "----; refusing to guess") + parts[2], parts[3] = ident.python_tag, ident.abi_tag + return "-".join(parts) + ".whl" + + +def ft_wheel_metadata(wheel_file: bytes, ident: FtIdentity) -> bytes: + """Rewrite every `Tag:` line of a WHEEL file, keeping the platform part.""" + out = [] + for line in wheel_file.splitlines(): + if line.startswith(b"Tag: "): + plat = line.rsplit(b"-", 1)[1] + line = b"Tag: " + ident.tag_prefix.encode() + b"-" + plat + out.append(line + b"\n") + return b"".join(out) + + +def interpreter_variables(python: str) -> dict: + """The four sysconfig variables of a runnable interpreter.""" + code = ("import json,sysconfig;print(json.dumps({k:sysconfig.get_config_var(k) " + f"for k in {_VARS!r}}}))") + r = subprocess.run([python, "-c", code], capture_output=True, text=True) + if r.returncode != 0: + raise FtRefusal(f"{python} could not report its sysconfig: {r.stderr.strip()}") + return json.loads(r.stdout) + + +#: Targets a free-threaded wheel can be derived for. Linux cross targets read +#: python-build-standalone's `-freethreaded` distribution. +FT_TARGETS = ("host", "linux-x86_64", "linux-aarch64") + +_WINDOWS = ("Windows CPython ships no _sysconfigdata, and WindowsTarget is written " + "against python3.lib / python313.dll, so there is no 3.15t identity " + "to read and no import library to link") +_NO_DIST = ("no free-threaded 3.15t distribution exists for this platform " + "(BeeWare's 3.15-b0 is GIL-only)") + +FT_REFUSALS = { + "windows-x86_64": f"cannot build for 3.15t: {_WINDOWS}", + "windows-arm64": f"cannot build for 3.15t: {_WINDOWS}", + "ios-arm64": f"cannot build for 3.15t: {_NO_DIST}", + "ios-arm64-sim": f"cannot build for 3.15t: {_NO_DIST}", + "android-arm64-v8a": f"cannot build for 3.15t: {_NO_DIST}", + "android-x86_64": f"cannot build for 3.15t: {_NO_DIST}", + "wasm32-emscripten": ("cannot build for 3.15t: Pyodide is not a " + "free-threaded interpreter"), +} diff --git a/tests/release/test_ftwheel.py b/tests/release/test_ftwheel.py new file mode 100644 index 00000000..0368765a --- /dev/null +++ b/tests/release/test_ftwheel.py @@ -0,0 +1,280 @@ +"""The free-threaded wheel variant (issue #51): `cp315-cp315t`, beside the abi3 one. + +`scripts/wheel/build.py --abi ft` builds a second wheel for CPython 3.15t. The +abi3 wheel cannot load there (a free-threaded interpreter has no stable ABI), +so the extension is built without `abi3` and carries a version-pinned name and +tag. Everything version-shaped is read out of the target interpreter's +sysconfig (`Py_GIL_DISABLED`, `VERSION`, `SOABI`, `EXT_SUFFIX`), the same way +the abi3 path reads its platform tag out of a target CPython, so nothing is +hand-written. + +What this suite checks, and what each check cannot see: + +* **Derivation** (`scripts/wheel/ftabi.py`), from fake sysconfig dicts for + Linux, macOS and Windows: tag, extension suffix and archive member. It cannot + see whether a real 3.15t interpreter agrees; that is `test_freethreaded.py`'s + subprocess, which needs a 3.15t install. +* **The refusals by name**: a GIL interpreter, a missing `Py_GIL_DISABLED`, an + inconsistent suffix, and every target that has no 3.15t interpreter. +* **build.py wiring**: `--abi ft` reaches those refusals before any build. +* **The workflow**: the `build-ft` job exists, covers exactly the targets that + can be derived, fetches the pinned python-build-standalone freethreaded + archive, and `collect` names the ft wheels. + +What none of it can see: that a built wheel loads on a device. iOS and Android +are refused, so they are not even "builds". +""" + +import os +import pathlib +import re +import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_support")) +import _skip # noqa: E402 + +REPO = next(p for p in pathlib.Path(__file__).resolve().parents if (p / "AGENTS.md").is_file()) +sys.path.insert(0, str(REPO / "scripts" / "wheel")) + +import ftabi # noqa: E402 + +WORKFLOW = REPO / ".github/workflows/publish-pypi.yml" +BUILD_PY = REPO / "scripts/wheel/build.py" + +LINUX = {"Py_GIL_DISABLED": 1, "VERSION": "3.15", "SOABI": "cpython-315t-x86_64-linux-gnu", + "EXT_SUFFIX": ".cpython-315t-x86_64-linux-gnu.so"} +MACOS = {"Py_GIL_DISABLED": 1, "VERSION": "3.15", "SOABI": "cpython-315t-darwin", + "EXT_SUFFIX": ".cpython-315t-darwin.so"} +WINDOWS = {"Py_GIL_DISABLED": 1, "VERSION": "3.15", "SOABI": "cp315t-win_amd64", + "EXT_SUFFIX": ".cp315t-win_amd64.pyd"} + + +def _refusal(variables): + try: + ftabi.ft_identity(variables) + except ftabi.FtRefusal as exc: + return str(exc) + raise AssertionError(f"{variables} was accepted") + + +# ----------------------------------------------------------- derivation + +def test_linux_identity_comes_from_the_interpreters_sysconfig(): + ident = ftabi.ft_identity(LINUX) + assert ident.python_tag == "cp315", ident + assert ident.abi_tag == "cp315t", ident + assert ident.tag_prefix == "cp315-cp315t", ident + assert ident.ext_suffix == ".cpython-315t-x86_64-linux-gnu.so", ident + assert ident.member == "torch/_C.cpython-315t-x86_64-linux-gnu.so", ident + + +def test_macos_member_is_the_darwin_suffix(): + assert ftabi.ft_identity(MACOS).member == "torch/_C.cpython-315t-darwin.so" + + +def test_windows_member_is_a_pyd(): + ident = ftabi.ft_identity(WINDOWS) + assert ident.member == "torch/_C.cp315t-win_amd64.pyd", ident + assert ident.tag_prefix == "cp315-cp315t", ident + + +def test_the_tag_follows_the_version_not_a_constant(): + """A 3.16t interpreter must produce cp316, not a hard-coded cp315.""" + v = {"Py_GIL_DISABLED": 1, "VERSION": "3.16", "SOABI": "cpython-316t-darwin", + "EXT_SUFFIX": ".cpython-316t-darwin.so"} + assert ftabi.ft_identity(v).tag_prefix == "cp316-cp316t" + + +def test_value_may_be_the_string_one(): + """`sysconfig.get_config_var` returns int on POSIX; a pickled dict may not.""" + assert ftabi.ft_identity({**LINUX, "Py_GIL_DISABLED": "1"}).abi_tag == "cp315t" + + +# ------------------------------------------------------------- refusals + +def test_a_gil_interpreter_is_refused_by_name(): + msg = _refusal({**LINUX, "Py_GIL_DISABLED": 0, + "SOABI": "cpython-315-x86_64-linux-gnu", + "EXT_SUFFIX": ".cpython-315-x86_64-linux-gnu.so"}) + assert "not free-threaded" in msg and "Py_GIL_DISABLED" in msg, msg + + +def test_a_missing_py_gil_disabled_is_refused_not_assumed_false_or_true(): + v = dict(LINUX) + del v["Py_GIL_DISABLED"] + msg = _refusal(v) + assert "Py_GIL_DISABLED" in msg, msg + assert _refusal({**LINUX, "Py_GIL_DISABLED": None}), "None was accepted" + + +def test_a_suffix_that_disagrees_with_the_version_is_refused(): + msg = _refusal({**LINUX, "EXT_SUFFIX": ".cpython-314t-x86_64-linux-gnu.so"}) + assert "EXT_SUFFIX" in msg and "315t" in msg, msg + + +def test_a_soabi_that_disagrees_with_the_version_is_refused(): + msg = _refusal({**LINUX, "SOABI": "cpython-315-x86_64-linux-gnu"}) + assert "SOABI" in msg and "315t" in msg, msg + + +def test_an_abi3_suffix_is_refused(): + """`.abi3.so` would mean the abi3 build, which cannot load on 3.15t.""" + msg = _refusal({**LINUX, "EXT_SUFFIX": ".abi3.so"}) + assert "EXT_SUFFIX" in msg, msg + + +def test_every_target_is_either_derivable_or_refused_by_name(): + import build # noqa: E402 + keys = {"host", *build.TARGETS} + derivable, refused = set(ftabi.FT_TARGETS), set(ftabi.FT_REFUSALS) + assert not derivable & refused, derivable & refused + assert derivable | refused == keys, ( + f"unclassified: {sorted(keys - derivable - refused)}; " + f"stale: {sorted((derivable | refused) - keys)}") + for key, why in ftabi.FT_REFUSALS.items(): + assert why.strip() and "3.15t" in why, (key, why) + + +def test_ios_and_android_name_why_no_interpreter_exists(): + for key in ("ios-arm64", "ios-arm64-sim", "android-arm64-v8a", "android-x86_64"): + assert key in ftabi.FT_REFUSALS, key + assert "free-threaded" in ftabi.FT_REFUSALS[key], key + + +# --------------------------------------------------------------- naming + +def test_wheel_filename_is_retagged_to_the_ft_tag(): + ident = ftabi.ft_identity(MACOS) + got = ftabi.ft_wheel_name("torchnative-0.1.0-cp313-abi3-macosx_11_0_arm64.whl", ident) + assert got == "torchnative-0.1.0-cp315-cp315t-macosx_11_0_arm64.whl", got + + +def test_wheel_file_tag_lines_are_rewritten_whole(): + ident = ftabi.ft_identity(LINUX) + old = b"Wheel-Version: 1.0\nTag: cp313-abi3-manylinux_2_17_x86_64\nRoot-Is-Purelib: false\n" + new = ftabi.ft_wheel_metadata(old, ident) + assert b"Tag: cp315-cp315t-manylinux_2_17_x86_64\n" in new, new + assert b"abi3" not in new and b"cp313" not in new, new + assert b"Root-Is-Purelib: false\n" in new, new + + +def test_a_wheel_name_that_is_not_five_parts_is_refused(): + ident = ftabi.ft_identity(LINUX) + try: + ftabi.ft_wheel_name("torchnative-0.1.0-py3-none-any-extra-parts.whl", ident) + except ftabi.FtRefusal: + return + raise AssertionError("a malformed wheel name was retagged") + + +# ---------------------------------------------------------- build.py CLI + +def _run_build(*args, env=None): + return subprocess.run([sys.executable, str(BUILD_PY), *args], capture_output=True, + text=True, cwd=REPO, timeout=120, env=env) + + +def test_abi_ft_on_ios_refuses_by_name_before_building(): + r = _run_build("--abi", "ft", "--target", "ios-arm64") + assert r.returncode != 0, r.stdout + out = r.stdout + r.stderr + assert "--abi ft" in out and "ios-arm64" in out and "free-threaded" in out, out + + +def test_abi_ft_on_android_refuses_by_name_before_building(): + r = _run_build("--abi", "ft", "--target", "android-arm64-v8a") + assert r.returncode != 0, r.stdout + assert "android-arm64-v8a" in (r.stdout + r.stderr), r.stderr + + +def test_abi_ft_with_a_gil_interpreter_refuses_by_name(): + r = _run_build("--abi", "ft", "--ft-python", sys.executable) + assert r.returncode != 0, r.stdout + assert "not free-threaded" in (r.stdout + r.stderr), r.stdout + r.stderr + + +def test_abi_ft_host_without_an_interpreter_refuses_by_name(): + env = {k: v for k, v in os.environ.items() if k != "PYO3_PYTHON"} + r = _run_build("--abi", "ft", env=env) + assert r.returncode != 0, r.stdout + assert "--ft-python" in (r.stdout + r.stderr), r.stdout + r.stderr + + +def test_the_default_abi_is_still_abi3(): + text = BUILD_PY.read_text() + assert re.search(r'add_argument\("--abi",[^)]*default="abi3"', text, re.S), ( + "build.py's --abi no longer defaults to abi3; the ft variant must sit " + "beside the abi3 wheel, not replace it") + + +# -------------------------------------------------------------- workflow + +def _yaml(): + try: + import yaml + except ImportError: + return None + return yaml.safe_load(WORKFLOW.read_text()) + + +def test_the_workflow_has_a_build_ft_job_for_exactly_the_derivable_targets(): + doc = _yaml() + if doc is None: + raise _skip.Skip("pyyaml is not installed in this interpreter") + job = doc["jobs"]["build-ft"] + got = tuple(sorted(e["target"] for e in job["strategy"]["matrix"]["include"])) + assert got == tuple(sorted(ftabi.FT_TARGETS)), got + needs = job.get("needs") or [] + assert "preflight" in needs and "vendor" in needs, needs + + +def test_collect_needs_build_ft_and_names_the_ft_wheels(): + doc = _yaml() + if doc is None: + raise _skip.Skip("pyyaml is not installed in this interpreter") + needs = doc["jobs"]["collect"]["needs"] + assert "build-ft" in needs and "build" in needs and "gate" in needs, needs + text = WORKFLOW.read_text() + for plat in ("macosx_11_0_arm64", "manylinux_2_17_x86_64", "manylinux_2_17_aarch64"): + assert f"cp315-cp315t-{plat}" in text, plat + + +def test_the_ft_interpreter_is_the_pinned_pbs_freethreaded_archive(): + text = WORKFLOW.read_text() + assert re.search(r'PBS_FT_PYTHON: "3\.15\.0rc1"', text), "PBS_FT_PYTHON is not pinned to 3.15" + assert "freethreaded-install_only.tar.gz" in text, ( + "the ft interpreter is not the freethreaded install_only archive") + assert "--no-default-features" in text, "the ft cargo build keeps abi3" + + +def test_ft_build_does_not_disable_its_own_assertions(): + doc = _yaml() + if doc is None: + raise _skip.Skip("pyyaml is not installed in this interpreter") + job = doc["jobs"]["build-ft"] + assert not job.get("continue-on-error") + for step in job["steps"]: + assert not step.get("continue-on-error"), step.get("name") + for line in (step.get("run") or "").splitlines(): + s = line.strip() + if s.startswith("#"): + continue + assert not s.endswith("|| true") and "set +e" not in s, (step.get("name"), s) + + +def test_ft_build_does_not_revendor_and_uses_its_own_cargo_dir(): + doc = _yaml() + if doc is None: + raise _skip.Skip("pyyaml is not installed in this interpreter") + job = doc["jobs"]["build-ft"] + assert "vendor_torch.sh" not in str(job["steps"]) + assert "TORCHNATIVE_FT_CARGO_TARGET_DIR" in str(job), ( + "the ft build must not share a cargo target dir with the abi3 host shim") + + +if __name__ == "__main__": + raise SystemExit(_skip.run_tests( + [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)], + "release/test_ftwheel", + )) From 2a87a947d3be8d90db0e868b2dc7ee1f20cceda5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:07:16 +0900 Subject: [PATCH 3/8] build.py verify: look for the ft member on cross targets too Dry run 37287742911 built both Linux cp315t wheels and then refused them: verify() remapped `expected` for ft but still searched the archive for the target's abi3 member. The host passed only because its target is None. test_verify_finds_the_ft_member_for_a_cross_target was red on the old code. --- scripts/wheel/build.py | 8 +++++--- tests/release/test_ftwheel.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/wheel/build.py b/scripts/wheel/build.py index 473dcd4c..a38ba4b9 100644 --- a/scripts/wheel/build.py +++ b/scripts/wheel/build.py @@ -2414,10 +2414,12 @@ def verify(wheel: Path, expected: set[str], target: "Target | None", # the extra went in. This is the only place that looks at what is # actually in the file that will be published. plat = wheel.stem.split("-")[-1] - member = next( - (n for n in names if n.endswith(target.extension_member)), None) + # A free-threaded wheel carries the version-pinned name instead of + # the target's abi3 one (`expected` was remapped the same way above). + want = ft.member if ft is not None else target.extension_member + member = next((n for n in names if n.endswith(want)), None) if member is None: - _fail(f"{wheel.name} has no {target.extension_member} -- the " + _fail(f"{wheel.name} has no {want} -- the " "extension is either missing or under a name this target's " "interpreter does not search") target.check_image(zf.read(member), f"{wheel.name}::{member}") diff --git a/tests/release/test_ftwheel.py b/tests/release/test_ftwheel.py index 0368765a..28538991 100644 --- a/tests/release/test_ftwheel.py +++ b/tests/release/test_ftwheel.py @@ -170,6 +170,32 @@ def test_a_wheel_name_that_is_not_five_parts_is_refused(): # ---------------------------------------------------------- build.py CLI +def test_verify_finds_the_ft_member_for_a_cross_target(): + # Dry run 37287742911: both Linux ft wheels were built and then refused by + # verify(), which looked for the abi3 member name on cross targets (target + # not None) and only remapped `expected`. The host passed only because its + # target is None and skips that lookup. + import tempfile + import zipfile + import build + ident = ftabi.ft_identity(LINUX) + seen = [] + + class Cross: + extension_member = build.Target.extension_member + global_deps_name = None + + def check_image(self, data, where): + seen.append(where) + + with tempfile.TemporaryDirectory(dir=REPO / ".scratch") as d: + whl = pathlib.Path(d) / "torchnative-0-cp315-cp315t-manylinux_2_17_x86_64.whl" + with zipfile.ZipFile(whl, "w") as zf: + zf.writestr(ident.member, b"\x7fELF") + build.verify(whl, {build.Target.extension_member}, Cross(), {}, "torchnative-0.dist-info", ft=ident) + assert seen and seen[0].endswith(ident.member), seen + + def _run_build(*args, env=None): return subprocess.run([sys.executable, str(BUILD_PY), *args], capture_output=True, text=True, cwd=REPO, timeout=120, env=env) From da9a3c502a5f259625ee09df26f0d2751887fc22 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:18:46 +0900 Subject: [PATCH 4/8] Grad mode and the torch-function mode stack are per thread, as upstream's are (#76) Both were process-wide: torch.no_grad() in one thread stopped grad_fn recording in another, and torch.device('meta') on one thread moved another thread's factories to meta. Wrong with the GIL on, not only on 3.15t. tensor.rs GRAD_ENABLED is a thread_local Cell; the bootstrap's grad flag and _MODE_STACK sit on threading.local. tests/api/test_threadstate.py: 3 tests, green on upstream torch, red on the shim before, green after. --- .github/scripts/gate_suites.py | 1 + tests/api/test_threadstate.py | 103 ++++++++++++++++++++++ torchnative/rust/torch_c/src/bootstrap.py | 54 +++++++++--- torchnative/rust/torch_c/src/tensor.rs | 21 +++-- 4 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 tests/api/test_threadstate.py diff --git a/.github/scripts/gate_suites.py b/.github/scripts/gate_suites.py index 105afdd9..b3a8412d 100644 --- a/.github/scripts/gate_suites.py +++ b/.github/scripts/gate_suites.py @@ -83,6 +83,7 @@ SUITES = { "_support/test_shim.py": BOTH, "api/test_import.py": BOTH, + "api/test_threadstate.py": BOTH, "api/test_tnnamespace.py": BOTH, "api/test_tntransformers.py": BOTH, "bindings/test_aliasinc.py": BOTH, diff --git a/tests/api/test_threadstate.py b/tests/api/test_threadstate.py new file mode 100644 index 00000000..214ca0c2 --- /dev/null +++ b/tests/api/test_threadstate.py @@ -0,0 +1,103 @@ +"""State that upstream keeps per thread must not leak between threads (#76). + +Upstream keeps grad mode (`c10/core/GradMode.h`) and the `torch.device(...)` +mode stack per thread. The shim held both process-wide, so `torch.no_grad()` +in one thread silently stopped another thread's `grad_fn` recording. That is +wrong with the GIL on, not only on a free-threaded interpreter: the GIL +serialises bytecode, it does not make a global per thread. + +Each test parks one thread inside a context manager (a no-grad region, a device +mode) and asserts from another thread that the state it sees is its own. The +same tests pass against upstream torch, which is how they were checked before +the fix. + +What this cannot see: a race that leaves the right answer. These tests check +isolation, not memory safety; the RNG stream free race of #76 needs the +free-threaded hammer in tests/api/test_freethreaded.py. +""" + +import os +import sys +import threading + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_support")) +sys.path.insert(0, os.path.join(str(next(p for p in __import__("pathlib").Path(__file__).resolve().parents if (p / "AGENTS.md").is_file())), "torchnative", "python")) + +import torch # noqa: E402 +import _skip # noqa: E402 + + +def _park(enter, seen): + """Run `enter()` in a thread, record what it sees inside, hold it open.""" + inside, release = threading.Event(), threading.Event() + + def body(): + with enter(): + seen["inside"] = torch.is_grad_enabled() + inside.set() + release.wait(10) + seen["after"] = torch.is_grad_enabled() + + t = threading.Thread(target=body) + t.start() + assert inside.wait(10), "parked thread never entered" + return t, release + + +def test_no_grad_in_another_thread_does_not_stop_recording_here(): + seen = {} + t, release = _park(torch.no_grad, seen) + try: + x = torch.ones(2, requires_grad=True) + y = x * 2 + here = torch.is_grad_enabled() + finally: + release.set() + t.join(10) + assert seen["inside"] is False, seen + assert here is True, "grad mode leaked from another thread's no_grad" + assert y.grad_fn is not None, "grad_fn not recorded: another thread's no_grad applied here" + + +def test_overlapping_no_grad_regions_restore_each_threads_own_mode(): + # Thread A enters no_grad, then the main thread enters and leaves its own. + # With one global flag, the main thread's exit restores A's False (or A's + # exit restores the main thread's), and someone is left with the wrong mode. + seen = {} + t, release = _park(torch.no_grad, seen) + try: + with torch.no_grad(): + assert torch.is_grad_enabled() is False + after_main = torch.is_grad_enabled() + finally: + release.set() + t.join(10) + assert after_main is True, "main thread left with another thread's mode" + assert seen["after"] is True, seen + + +def test_a_device_mode_in_another_thread_does_not_apply_here(): + inside, release = threading.Event(), threading.Event() + + def body(): + with torch.device("meta"): + inside.set() + release.wait(10) + + t = threading.Thread(target=body) + t.start() + try: + assert inside.wait(10) + dev = torch.empty(2).device + finally: + release.set() + t.join(10) + assert dev.type == "cpu", f"another thread's torch.device('meta') applied here: {dev}" + + +if __name__ == "__main__": + assert hasattr(torch._C, "_aten_implemented") or os.environ.get("TORCHNATIVE_ORACLE") == "1", \ + "imported upstream torch, not the shim" + raise SystemExit(1 if _skip.run_tests( + [(n, f) for n, f in list(globals().items()) if n.startswith("test_") and callable(f)], + "api/test_threadstate") else 0) diff --git a/torchnative/rust/torch_c/src/bootstrap.py b/torchnative/rust/torch_c/src/bootstrap.py index b47cd319..ad5c3cc0 100644 --- a/torchnative/rust/torch_c/src/bootstrap.py +++ b/torchnative/rust/torch_c/src/bootstrap.py @@ -4969,13 +4969,40 @@ def _get_tensor_metadata(tensor): # `DeviceContext`; `torch/__init__.py` has `set_default_device` and # `get_default_device`. All five `_C` names they bottom out in are below. # -# **The stack is process-wide where upstream's is thread-local.** -# `PythonTorchFunctionTLS` is per-thread; this is one list. Recorded rather than -# fixed: a mode entered on one thread would be seen by another, which upstream -# would not do. Nothing in this shim's measured paths is multi-threaded, and the -# fix (a `threading.local`) is a two-line change in this block if that stops -# being true. -_MODE_STACK: list = [] +# **The stack is per thread**, as upstream's `PythonTorchFunctionTLS` is. It was +# one process-wide list, so `with torch.device("meta")` on one thread made +# another thread's `torch.empty` land on meta (#76). The proxy keeps the +# list-shaped spelling every caller below uses, over a `threading.local`. +class _ThreadModeStack: + __slots__ = ("_tls",) + + def __init__(self): + self._tls = threading.local() + + def _list(self) -> list: + try: + return self._tls.stack + except AttributeError: + self._tls.stack = [] + return self._tls.stack + + def append(self, mode): + self._list().append(mode) + + def pop(self): + return self._list().pop() + + def __len__(self): + return len(self._list()) + + def __bool__(self): + return bool(self._list()) + + def __getitem__(self, index): + return self._list()[index] + + +_MODE_STACK = _ThreadModeStack() def _through_torch_function_modes(func, args, kwargs): @@ -7181,11 +7208,10 @@ def _install_grad_mode(module, varfns) -> None: `torch.is_grad_enabled()` and `torch._C.is_grad_enabled()` disagree. """ state = { - "grad": True, # `torch/autograd/grad_mode.py:340` and `:393`. Both are real # backend-configuration switches (thread pool, layout enforcement) with # nothing behind them here, and both are context managers that restore - # what they read -- so, like `grad`, they have to round-trip. + # what they read -- so, like grad mode, they have to round-trip. "multithreading": True, "layout_enforcement": False, # `torch.is_inference_mode_enabled()`. Upstream spells this ONLY at @@ -7194,7 +7220,7 @@ def _install_grad_mode(module, varfns) -> None: # as the table-less refusal made `fake_tensor.py:1801` stop the whole of # `torch.export` on a predicate that has an obvious answer. # - # It lives HERE, in the same dict as `grad`, rather than as a constant, + # It lives HERE, in this dict rather than as a constant, # because docs/graph/EXPORT.md §2.2 is about exactly the other choice: a # constant `False` would make `with torch.inference_mode():` a block # that enters, reports itself absent, and changes nothing. The setter @@ -7203,8 +7229,12 @@ def _install_grad_mode(module, varfns) -> None: "inference": False, } + # Grad mode is per thread, as upstream's `GradMode` is (#76); the other + # entries in `state` stay process-wide, as their upstream switches are. + grad_tls = threading.local() + def is_grad_enabled(): - return state["grad"] + return getattr(grad_tls, "grad", True) def _set_grad_enabled(mode): # Two writes, one source of truth. `state` stays the value @@ -7214,7 +7244,7 @@ def _set_grad_enabled(mode): # differentiate. See `tensor.rs`'s `GRAD_ENABLED`, and # `test_no_grad_gates_grad_fn` for the two being checked against each # other rather than assumed equal. - state["grad"] = bool(mode) + grad_tls.grad = bool(mode) module._shim_set_grad_enabled_flag(bool(mode)) def is_inference_mode_enabled(): diff --git a/torchnative/rust/torch_c/src/tensor.rs b/torchnative/rust/torch_c/src/tensor.rs index 16708a6a..b76b209d 100644 --- a/torchnative/rust/torch_c/src/tensor.rs +++ b/torchnative/rust/torch_c/src/tensor.rs @@ -4048,10 +4048,13 @@ pub fn has_storage(value: &Bound<'_, PyAny>) -> PyResult { /// of an `AtomicBool` is the same shape as `capture::is_active`, which /// docs/graph/CAPTURE.md §7 already measured at the same door. /// -/// `Ordering::Relaxed` for the same reason capture uses it: there is nothing -/// else for this flag to be ordered *against*. A thread that flips it and then -/// dispatches does both under the GIL. -static GRAD_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +/// **Per thread**, as upstream's `GradMode` is (`c10/core/GradMode.h`). It was +/// one process-wide `AtomicBool`, so `torch.no_grad()` in one thread stopped +/// another thread's `grad_fn` recording, with the GIL on (#76). A `Cell` read is +/// no dearer at the door than the relaxed atomic load it replaces. +thread_local! { + static GRAD_ENABLED: std::cell::Cell = const { std::cell::Cell::new(true) }; +} /// `torch._C._set_throw_on_mutable_data_ptr(tensor)`. /// @@ -4111,7 +4114,7 @@ pub fn throws_on_mutable_data_ptr(tensor: PyRef<'_, PyTensorBase>) -> bool { #[pyfunction] #[pyo3(name = "_shim_set_grad_enabled_flag")] pub fn set_grad_enabled_flag(value: bool) { - GRAD_ENABLED.store(value, std::sync::atomic::Ordering::Relaxed); + GRAD_ENABLED.with(|g| g.set(value)); } /// Grad mode off for the duration of a scope, restored on drop. @@ -4125,20 +4128,20 @@ pub struct NoGradGuard(bool); impl NoGradGuard { pub fn enter() -> Self { - Self(GRAD_ENABLED.swap(false, std::sync::atomic::Ordering::Relaxed)) + Self(GRAD_ENABLED.with(|g| g.replace(false))) } } impl Drop for NoGradGuard { fn drop(&mut self) { - GRAD_ENABLED.store(self.0, std::sync::atomic::Ordering::Relaxed); + GRAD_ENABLED.with(|g| g.set(self.0)); } } #[pyfunction] #[pyo3(name = "_shim_grad_enabled_flag")] pub fn grad_enabled_flag() -> bool { - GRAD_ENABLED.load(std::sync::atomic::Ordering::Relaxed) + GRAD_ENABLED.with(|g| g.get()) } /// Ops whose output is **not** a differentiable function of their tensor @@ -4289,7 +4292,7 @@ pub fn mark_from_op( kwargs: Option<&Bound<'_, pyo3::types::PyDict>>, out: &Py, ) -> bool { - if !GRAD_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + if !GRAD_ENABLED.with(|g| g.get()) { return false; } if NOT_DIFFERENTIABLE.contains(&op) { From f506e52bf3d8114cf80c40bb8e1edaac23c7a921 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:42:39 +0900 Subject: [PATCH 5/8] RNG streams are Arc-held, so freeing a generator cannot pull a stream out from under a live draw (#76) Streams were Box::leak'ed and reclaimed by Box::from_raw in stream_free, sound only while a kernel's guard and the generator's __del__ could not overlap under the GIL. stream() now returns a GenGuard that holds its own Arc clone; stream_free drops the table's reference. The new cargo test frees a locked stream and reuses its memory: SIGSEGV on the old code, green now. --- torchnative/rust/torch_c/src/rng.rs | 97 ++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/torchnative/rust/torch_c/src/rng.rs b/torchnative/rust/torch_c/src/rng.rs index 388c1e25..76ce489c 100644 --- a/torchnative/rust/torch_c/src/rng.rs +++ b/torchnative/rust/torch_c/src/rng.rs @@ -236,19 +236,22 @@ pub fn default_generator() -> MutexGuard<'static, CpuGenerator> { /// Every `torch.Generator()` owns one `CpuGenerator`, kept here under a small /// integer id that the Python object carries as `_shim_gen_id`. /// -/// The stream is leaked (`Box::leak`) so a kernel can hold a -/// `MutexGuard<'static, CpuGenerator>` -- the same type `default_generator()` -/// returns -- and every kernel body stays as it was upstream-shaped: it takes -/// `&mut CpuGenerator` and does not care whose. `stream_free` reclaims it when -/// the Python object dies. That is sound because a kernel holds its guard only -/// while it runs, under the GIL, and `__del__` cannot run in the middle of one. +/// Each stream is an `Arc`, and `stream()` hands out a guard that holds its own +/// clone of it. It used to be `Box::leak`ed, with `stream_free` reclaiming it by +/// `Box::from_raw`, which was sound only because a kernel's guard and the +/// Python `__del__` that frees the stream could not both run at once "under +/// the GIL". On a free-threaded interpreter they can, and the guard would point +/// at freed memory (#76). Now `stream_free` only drops the table's reference: +/// a kernel that is still drawing keeps the generator alive until it finishes. /// /// `None` is the default generator, so a call that names no generator and a /// call that names `torch.default_generator` take the same path. -static STREAMS: OnceLock>>> = OnceLock::new(); +type Stream = std::sync::Arc>; + +static STREAMS: OnceLock>> = OnceLock::new(); static NEXT_STREAM: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); -fn streams() -> MutexGuard<'static, HashMap>> { +fn streams() -> MutexGuard<'static, HashMap> { match STREAMS.get_or_init(|| Mutex::new(HashMap::new())).lock() { Ok(g) => g, Err(p) => p.into_inner(), @@ -262,18 +265,38 @@ pub const DEFAULT_GENERATOR_SEED: u64 = 67280421310721; pub fn stream_new(seed: u64) -> u64 { let id = NEXT_STREAM.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let gen: &'static Mutex = Box::leak(Box::new(Mutex::new(CpuGenerator::new(seed)))); - streams().insert(id, gen); + streams().insert(id, std::sync::Arc::new(Mutex::new(CpuGenerator::new(seed)))); id } pub fn stream_free(id: u64) { - if let Some(gen) = streams().remove(&id) { - // SAFETY: `gen` came from `Box::leak` in `stream_new`, it was just - // removed from the only table that handed out references, and kernels - // lock a stream only while holding the GIL, which the caller of - // `stream_free` (a Python `__del__`) also holds -- so no guard is live. - drop(unsafe { Box::from_raw(gen as *const Mutex as *mut Mutex) }); + // Drops the table's reference only. A guard handed out by `stream()` + // holds its own, so the generator outlives this call if a kernel is + // still drawing from it. + streams().remove(&id); +} + +/// A locked generator: the default one, or a stream kept alive by the guard. +/// +/// Field order is the soundness argument. `guard` borrows from the `Arc` in +/// `_keep` (its `'static` is a lie told to the borrow checker), and fields drop +/// in declaration order, so the lock is released before the last reference +/// this guard owns can go. +pub struct GenGuard { + guard: MutexGuard<'static, CpuGenerator>, + _keep: Option, +} + +impl std::ops::Deref for GenGuard { + type Target = CpuGenerator; + fn deref(&self) -> &CpuGenerator { + &self.guard + } +} + +impl std::ops::DerefMut for GenGuard { + fn deref_mut(&mut self) -> &mut CpuGenerator { + &mut self.guard } } @@ -281,17 +304,23 @@ pub fn stream_free(id: u64) { /// default when `id` is `None`. An unknown id is a freed generator -- a bug on /// the Python side -- and panics rather than quietly drawing from another /// stream. -pub fn stream(id: Option) -> MutexGuard<'static, CpuGenerator> { +pub fn stream(id: Option) -> GenGuard { let Some(id) = id else { - return default_generator(); + return GenGuard { guard: default_generator(), _keep: None }; }; - let gen: &'static Mutex = *streams() + let keep: Stream = streams() .get(&id) + .cloned() .unwrap_or_else(|| panic!("torch._C shim: generator stream {id} was freed")); - match gen.lock() { + // SAFETY: the pointer is into the heap allocation `keep` owns, and `keep` + // moves into the same `GenGuard`, declared after `guard`, so it is dropped + // after the guard; the `Arc` allocation does not move when `keep` does. + let m: &'static Mutex = unsafe { &*std::sync::Arc::as_ptr(&keep) }; + let guard = match m.lock() { Ok(g) => g, Err(p) => p.into_inner(), - } + }; + GenGuard { guard, _keep: Some(keep) } } // --------------------------------------------------------------------------- @@ -835,3 +864,29 @@ pub fn register(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<() m.add_function(wrap_pyfunction!(shim_reseed, m)?)?; Ok(()) } + +#[cfg(test)] +mod stream_lifetime_tests { + use super::*; + + /// #76: a kernel's guard must keep its stream alive when the Python side + /// frees the generator mid-draw. Freeing, then allocating same-sized + /// streams with other seeds, is how the old `Box::leak`/`Box::from_raw` + /// scheme let the guard read another generator's state. + #[test] + fn a_live_guard_keeps_drawing_its_own_stream_after_stream_free() { + let seed = 12345; + let id = stream_new(seed); + let mut guard = stream(Some(id)); + stream_free(id); + let others: Vec = (0..256).map(|i| stream_new(1_000_000 + i)).collect(); + let mut reference = CpuGenerator::new(seed); + for _ in 0..64 { + assert_eq!(guard.random64(), reference.random64(), "guard drew from a reused allocation"); + } + drop(guard); + for o in others { + stream_free(o); + } + } +} From 992c69c9c5c3081f53957f174e9610b32ba7f8fc Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:25:39 +0900 Subject: [PATCH 6/8] Capture's activity flag counts open regions instead of one bool (#76); test_threadstate sets TORCH_USE_RTLD_GLOBAL CAPTURING was cleared by whichever thread ended its capture first, so a thread still mid-capture stopped recording and its later ops vanished from its trace. It is now an AtomicUsize of open regions (one per thread at most, since capture_begin refuses nesting); the door still pays one relaxed load. test_threadstate.py never ran in the gate before this commit: it died at import torch for want of TORCH_USE_RTLD_GLOBAL, which every other suite that imports torch sets. Now 4 ok in the gate (2010 -> 2014). --- tests/api/test_threadstate.py | 35 +++++++++++++++++++++++++ torchnative/rust/torch_c/src/capture.rs | 16 ++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tests/api/test_threadstate.py b/tests/api/test_threadstate.py index 214ca0c2..cca278a7 100644 --- a/tests/api/test_threadstate.py +++ b/tests/api/test_threadstate.py @@ -20,6 +20,9 @@ import sys import threading +# The shim imports upstream extension libraries that resolve only with global +# symbols, as every other suite that imports torch sets. +os.environ.setdefault("TORCH_USE_RTLD_GLOBAL", "1") sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_support")) sys.path.insert(0, os.path.join(str(next(p for p in __import__("pathlib").Path(__file__).resolve().parents if (p / "AGENTS.md").is_file())), "torchnative", "python")) @@ -95,6 +98,38 @@ def body(): assert dev.type == "cpu", f"another thread's torch.device('meta') applied here: {dev}" +def test_ending_a_capture_on_one_thread_does_not_stop_another_threads(): + # #76: the "is anyone recording" flag was one bool, cleared by whichever + # thread ended its capture first, while the recorders are per thread. The + # other thread's later ops then skipped recording and vanished from its + # trace. Here the main thread captures and ends while thread A is mid-capture. + C = torch._C + if not hasattr(C, "_capture_begin"): + raise _skip.Skip("no capture API (upstream torch)") + began, release, out = threading.Event(), threading.Event(), {} + + def body(): + x = torch.ones(3) + C._capture_begin([x]) + began.set() + release.wait(10) + y = x.neg() + out["ops"] = [n["op"] for n in C._capture_end(y).nodes] + + t = threading.Thread(target=body) + t.start() + try: + assert began.wait(10) + a = torch.ones(2) + C._capture_begin([a]) + C._capture_end(a.exp()) + finally: + release.set() + t.join(10) + assert any("neg" in op for op in out.get("ops", [])), \ + f"thread A's op was dropped after another thread ended its capture: {out}" + + if __name__ == "__main__": assert hasattr(torch._C, "_aten_implemented") or os.environ.get("TORCHNATIVE_ORACLE") == "1", \ "imported upstream torch, not the shim" diff --git a/torchnative/rust/torch_c/src/capture.rs b/torchnative/rust/torch_c/src/capture.rs index 3bc7280c..a65ec7db 100644 --- a/torchnative/rust/torch_c/src/capture.rs +++ b/torchnative/rust/torch_c/src/capture.rs @@ -61,7 +61,13 @@ use crate::tensor::PyTensorBase; /// Global rather than thread-local because reading a `thread_local!` costs a /// TLS lookup, and the recorder itself is thread-local anyway -- a second /// thread that takes the branch finds no recorder and falls straight back out. -static CAPTURING: AtomicBool = AtomicBool::new(false); +/// +/// A **count** of open regions, not a bool. As a bool it was cleared by +/// whichever thread ended its capture first, and every later op of a thread +/// still mid-capture skipped recording and vanished from its trace (#76). +/// `capture_begin` refuses a second region on the same thread, so each thread +/// contributes at most one, and only a thread that took its recorder decrements. +static CAPTURING: AtomicUsize = AtomicUsize::new(0); thread_local! { static RECORDER: RefCell> = const { RefCell::new(None) }; @@ -76,7 +82,7 @@ thread_local! { #[inline(always)] pub fn is_active() -> bool { - CAPTURING.load(Ordering::Relaxed) + CAPTURING.load(Ordering::Relaxed) != 0 } /// Whether the eager tape records at all. @@ -1738,7 +1744,7 @@ pub fn capture_begin(py: Python<'_>, inputs: &Bound<'_, PyAny>) -> PyResult<()> let _ = py; RECORDER.with(|cell| *cell.borrow_mut() = Some(rec)); - CAPTURING.store(true, Ordering::Relaxed); + CAPTURING.fetch_add(1, Ordering::Relaxed); Ok(()) } @@ -1753,7 +1759,9 @@ pub fn capture_abandon() -> PyResult<()> { fn take_recorder() -> PyResult { let taken = RECORDER.with(|cell| cell.borrow_mut().take()); - CAPTURING.store(false, Ordering::Relaxed); + if taken.is_some() { + CAPTURING.fetch_sub(1, Ordering::Relaxed); + } taken.ok_or_else(not_recording) } From d1f265e81b6459cb68b9b53e0a9ad1600e75fc18 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:00:35 +0900 Subject: [PATCH 7/8] 3.15t thread hammer with PYTHON_GIL=0: RNG streams, grad mode, device mode (#51, #76) 8 threads behind a Barrier in a 3.15t subprocess that overrides gil_used. Against a build without #76's fixes, grad and mode go red ('another thread's no_grad applied here', 'pop from an empty torch function mode stack'); with them all three are green. The RNG case stays green on the old code too: the use-after-free is proven by the cargo test, not by this hammer. The first version collected errors under the wrapper's name and so could never fail; the nullification above is how that was found. Also git-ignores target-ft/, build.py --abi ft's default cargo dir. --- .gitignore | 2 + tests/api/test_freethreaded.py | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/.gitignore b/.gitignore index cecd8c47..bdeb7434 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,8 @@ cython_debug/ # Rust: build output lives in CARGO_TARGET_DIR on the external volume /torchnative/rust/*/target/ +# build.py --abi ft builds into its own dir (TORCHNATIVE_FT_CARGO_TARGET_DIR), #51 +/torchnative/rust/*/target-ft/ # Vendored upstream trees (docs/platform/VENDOR.md). The torch Python tree alone is # ~1084 modules; it is reproduced by scripts/vendor/vendor_torch.sh, never committed. diff --git a/tests/api/test_freethreaded.py b/tests/api/test_freethreaded.py index 00477bb1..d8589551 100644 --- a/tests/api/test_freethreaded.py +++ b/tests/api/test_freethreaded.py @@ -70,6 +70,113 @@ def test_module_keeps_the_gil_until_the_audit_lands(): assert any("torch._C" in w for w in r["warned"]), r["warned"] +# The thread hammer (#51, #76). `PYTHON_GIL=0` overrides the module's +# `gil_used = true`, so the threads really run at once: each case below failed, +# or was unsound, while the state it touches was process-global. +_HAMMER = r""" +import json, sys, threading +import torch +assert hasattr(torch._C, "_aten_implemented") +assert not sys._is_gil_enabled(), "PYTHON_GIL=0 did not take" +N, ROUNDS = 8, 40 +bar = threading.Barrier(N) +errors, result = [], {} + +def guard(fn): + def run(i): + try: + fn(i) + except BaseException as e: + errors.append(f"{fn.__name__}[{i}]: {type(e).__name__}: {e}") + bar.abort() + return run + +def ref_draws(seed): + g = torch.Generator().manual_seed(seed) + return [torch.randn(64, generator=g).tolist() for _ in range(ROUNDS)] + +@guard +def rng(i): + want = ref_draws(1000 + i) + bar.wait() + g = torch.Generator().manual_seed(1000 + i) + for r in range(ROUNDS): + churn = torch.Generator().manual_seed(r) # freed every round + torch.rand(8, generator=churn) + del churn + got = torch.randn(64, generator=g).tolist() + assert got == want[r], f"seed {1000 + i} round {r} drew another stream" + +@guard +def grad(i): + bar.wait() + for _ in range(ROUNDS): + if i % 2: + with torch.no_grad(): + assert not torch.is_grad_enabled() + y = torch.ones(4, requires_grad=True) * 2 + assert y.grad_fn is None + else: + assert torch.is_grad_enabled(), "another thread's no_grad applied here" + y = torch.ones(4, requires_grad=True) * 2 + assert y.grad_fn is not None, "grad_fn dropped: another thread's no_grad" + +@guard +def mode(i): + bar.wait() + for _ in range(ROUNDS): + if i % 2: + with torch.device("meta"): + assert torch.empty(2).device.type == "meta" + else: + d = torch.empty(2).device.type + assert d == "cpu", f"another thread's device mode applied here: {d}" + +for name, fn in [("rng", rng), ("grad", grad), ("mode", mode)]: + bar.reset() + ts = [threading.Thread(target=fn, args=(i,)) for i in range(N)] + [t.start() for t in ts] + [t.join(120) for t in ts] + result[name] = [e for e in errors if e.startswith(name + "[")] +print(json.dumps(result)) +""" + + +def _hammer(): + py = os.environ.get("TORCHNATIVE_FT_PYTHON") + stage = os.environ.get("TORCHNATIVE_FT_STAGE") + if not py or not stage: + raise _skip.Skip("TORCHNATIVE_FT_PYTHON / TORCHNATIVE_FT_STAGE not set (issue #51)") + env = dict(os.environ, PYTHONPATH=stage, TORCH_USE_RTLD_GLOBAL="1", PYTHON_GIL="0") + proc = subprocess.run([py, "-c", _HAMMER], capture_output=True, text=True, env=env, timeout=600) + if proc.returncode != 0: + raise RuntimeError(f"hammer exited {proc.returncode}\n{proc.stderr[-3000:]}") + import json + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +_HAMMER_RESULT = {} + + +def _case(name): + if not _HAMMER_RESULT: + _HAMMER_RESULT.update(_hammer()) + errs = _HAMMER_RESULT[name] + assert not errs, "\n".join(errs[:5]) + + +def test_hammer_rng_streams_stay_their_own_under_churn(): + _case("rng") + + +def test_hammer_grad_mode_is_per_thread_without_the_gil(): + _case("grad") + + +def test_hammer_device_mode_is_per_thread_without_the_gil(): + _case("mode") + + if __name__ == "__main__": raise SystemExit(_skip.run_tests( [(n, f) for n, f in list(globals().items()) if n.startswith("test_") and callable(f)], From 593d690418ccfb04da99baf78c9d251c6472b155 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:59:09 +0900 Subject: [PATCH 8/8] publish-pypi: cp315t wheels build against CPython 3.15.0 final (#51) PBS release 20261013 ships 3.15.0 freethreaded for aarch64-apple-darwin and both linux-gnu triples. The ft rows now fetch from their own PBS_FT_RELEASE so the cp313-abi3 interpreter pin (PBS_RELEASE 20260825, 3.13.15) stays where the published 0.1.0b5 wheels were built. test_ftwheel now requires 3.15.0 final and no 3.15 pre-release anywhere; it was red on the rc1 pin. Locally: _C built against PBS 3.15.0t, tests/api/test_freethreaded.py 5/5 ok (including the PYTHON_GIL=0 hammer). --- .github/workflows/publish-pypi.yml | 8 +++++--- tests/release/test_ftwheel.py | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3c55bb1e..2e43a79f 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -72,8 +72,10 @@ env: PBS_RELEASE: "20260825" PBS_PYTHON: "3.13.15" # The free-threaded interpreter the cp315-cp315t wheels are built against - # (issue #51). PBS_RELEASE ships its `-freethreaded-install_only` archives. - PBS_FT_PYTHON: "3.15.0rc1" + # (issue #51): CPython 3.15.0 final, from its own PBS release so the + # cp313-abi3 interpreter above (PBS_RELEASE/PBS_PYTHON) does not move with it. + PBS_FT_RELEASE: "20261013" + PBS_FT_PYTHON: "3.15.0" PYTHONUNBUFFERED: "1" jobs: @@ -683,7 +685,7 @@ jobs: set -eu mkdir -p _target-python _dl triple="${{ matrix.triple }}" - url="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_FT_PYTHON}+${PBS_RELEASE}-${triple}-freethreaded-install_only.tar.gz" + url="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_FT_RELEASE}/cpython-${PBS_FT_PYTHON}+${PBS_FT_RELEASE}-${triple}-freethreaded-install_only.tar.gz" curl -sSfL "$url" -o "_dl/${triple}.tar.gz" rm -rf _dl/python tar -C _dl -xzf "_dl/${triple}.tar.gz" diff --git a/tests/release/test_ftwheel.py b/tests/release/test_ftwheel.py index 28538991..931955c2 100644 --- a/tests/release/test_ftwheel.py +++ b/tests/release/test_ftwheel.py @@ -268,7 +268,13 @@ def test_collect_needs_build_ft_and_names_the_ft_wheels(): def test_the_ft_interpreter_is_the_pinned_pbs_freethreaded_archive(): text = WORKFLOW.read_text() - assert re.search(r'PBS_FT_PYTHON: "3\.15\.0rc1"', text), "PBS_FT_PYTHON is not pinned to 3.15" + # 3.15.0 final (2026-10), not a release candidate, from its own PBS release + # so the cp313-abi3 interpreter pin (PBS_RELEASE/PBS_PYTHON) does not move. + assert re.search(r'PBS_FT_PYTHON: "3\.15\.0"', text), "PBS_FT_PYTHON is not pinned to 3.15.0 final" + assert re.search(r'PBS_FT_RELEASE: "20261013"', text), "PBS_FT_RELEASE is not the release that ships 3.15.0" + assert "releases/download/${PBS_FT_RELEASE}/cpython-${PBS_FT_PYTHON}+${PBS_FT_RELEASE}-" in text, ( + "the ft archive is not fetched from PBS_FT_RELEASE") + assert not re.search(r"3\.15\.0(rc|a|b)\d", text), "a 3.15 pre-release is still referenced" assert "freethreaded-install_only.tar.gz" in text, ( "the ft interpreter is not the freethreaded install_only archive") assert "--no-default-features" in text, "the ft cargo build keeps abi3"