Skip to content

Support free-threaded CPython in native extensions - #127

Open
Borda wants to merge 6 commits into
mainfrom
perf/cpp_cache
Open

Borda wants to merge 6 commits into
mainfrom
perf/cpp_cache

Conversation

@Borda

@Borda Borda commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Enable faster_coco_eval to load on free-threaded CPython without re-enabling the GIL for the entire process.

  • Declare both pybind11 extension modules safe for free-threaded interpreters.
  • Synchronize the mutable native Dataset cache and avoid exposing cache references after their lock is released.
  • Require the first pybind11 release that provides mod_gil_not_used.
  • Run the normal installed-sdist suite on CPython 3.10-3.14 across Linux, macOS, and Windows, plus no-GIL gates on 3.13t and 3.14t.

Why this matters

Before this change, importing either native extension on CPython 3.14t warns and enables the GIL process-wide. Evaluation results remain correct, but other Python threads lose free-threaded execution. With this change, the GIL remains disabled after both extensions are imported.

The expected benefit is concurrent throughput, not lower latency for one COCO evaluation.

Python 3.14t benchmark

CPython 3.14.2 free-threaded on macOS arm64. Each row uses two warmups and 15 alternating fresh-process base/PR pairs. The bbox workload contains 200 images and 10,000 detections with maxDets=[1, 10, 50]; each worker owns an independent evaluator.

Concurrent evaluators Base PR Throughput gain Paired 95% interval Favorable pairs
1 14.06 eval/s 14.20 eval/s 1.01x 0.995-1.028x 9/15
2 11.68 eval/s 19.01 eval/s 1.61x 1.49-1.66x 15/15
4 7.69 eval/s 29.07 eval/s 3.76x 3.54-4.00x 15/15

Four concurrent evaluations complete in 0.13760 seconds with the PR versus 0.52048 seconds on the base revision. Every run produced the same exact result digest.

Standard CPython 3.13.15 also produced the same digest. An extended alternating A/B run measured a 0.91% total-time change and a -0.03% peak-RSS change, so the synchronization cost remained below the predeclared 3% regression guard.

Benchmark revisions:

  • Base: 123cc73a1da42420d77e230c9d02cba8b4d83ac3
  • Measured implementation: b67e5d163c0ee350051962e8f1351188a0ddeec0

Safety and reliability

  • All mutable Dataset containers and cache versions are mutex-protected.
  • Python conversions and Python reference destruction occur outside the mutex, preventing callback/finalizer re-entry deadlocks.
  • Cache misses publish parsed data only if the annotation version still matches the source snapshot.
  • Cached annotations return by value so concurrent eviction cannot leave a dangling reference.
  • CI runs the standard sdist suite on CPython 3.10-3.14 across three operating systems. Separate 3.13t/3.14t jobs import outside the checkout, treat warnings as errors, check that the GIL remains disabled, and run the contention regressions.

Verification

  • CPython 3.14.2t: both extensions imported with the GIL still disabled.
  • CPython 3.14.2t: 16 passed in test_cpp_safety_regressions.py.
  • CPython 3.10.11: full suite 211 passed, 2 skipped.
  • C++ formatting hook: passed on the focused evaluator-module change.
  • Hosted standard 3.14 and free-threaded 3.13t/3.14t jobs: pending until this PR revision runs in GitHub Actions.

Claim boundary

This PR does not make one evaluation materially faster and does not claim universal linear scaling, shared-mutable-Dataset scaling, or cross-platform equivalence. The measured 1.61-3.76x gain applies to 2-4 independent evaluators on one synthetic bbox workload and one macOS arm64 host.

Borda and others added 2 commits August 24, 2026 16:58
Changes:
- Guard LightweightDataset's annotation_refs and cpp_cache with a mutex held by every public method, so concurrent Python threads can no longer corrupt them.
- Return get_cpp_annotations by value instead of by reference; the cached entry it exposed can be erased by clear_cache_entry from another thread, and every caller already copied the result.
- Add a private get_cpp_annotations_locked helper so get_cpp_instances takes one lock for its whole sweep rather than one per image/category pair.
- Add move construction and assignment that transfer the containers under the source's lock and leave each object its own mutex, restoring the movability the pickle __setstate__ factory needs; copying is now explicitly deleted.
- Declare pybind11::mod_gil_not_used() on faster_eval_api_cpp and mask_api_new_cpp.
- Raise the pybind11 build requirement to >=2.13.0, the first release exposing mod_gil_not_used.
- Advertise Python 3.14 in the classifiers.
- Add TestDatasetConcurrency exercising concurrent readers and reads interleaved with cache eviction.

Rationale:
- An extension that does not declare Py_MOD_GIL_NOT_USED makes CPython re-enable the GIL for the whole process when it is imported, silently disabling free-threading for every library in that process. The pinned cibuildwheel builds cp314t wheels by default and no skip pattern excludes them, so those wheels would ship undeclared. The declaration is a thread-safety promise, so the dataset cache had to be guarded before making it.
- mask_api needed no locking change: its only process-wide mutable state is ParallelPermitPool, already guarded by a mutex and condition variable because those APIs release the GIL and are entered concurrently.

Impact:
- Measured on a 2,000 image x 300 detection synthetic bbox workload, maxDets=[1,10,500], interleaved A/B over frozen builds, 6 rounds each, medians: evaluate 0.85495s -> 0.85492s (-0.00%); _prepare 0.32861s -> 0.33304s (+1.35%, within a median absolute deviation of 0.0067 and 0.0045).
- Evaluator output digest over precision, recall, scores and summary stats is unchanged.

Verification:
- Full suite: 204 passed, 2 skipped.
- Pickle round trip exercises the new move constructor and preserves both stored annotations and their parsed form.
- ruff check and ruff format clean.

Residual limits:
- pybind11 compiles the declaration under #ifdef Py_GIL_DISABLED, so on a GIL-enabled interpreter it is inert and its runtime effect could not be exercised here; verifying it needs a free-threaded build, which this host does not have.
- The concurrency tests run under the GIL, so they demonstrate absence of deadlock and correct results rather than absence of data races.
- Locking is per dataset instance; append_ref takes the lock once per annotation, which is the source of the small _prepare change.

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda
Borda requested a balanced review from Copilot August 24, 2026 15:00
@Borda Borda added the enhancement New feature or request label Aug 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Mutex-protected Python callbacks can deadlock, cache invalidation is incomplete, and the claimed pickle path lacks coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Declares both native modules compatible with free-threaded CPython and synchronizes shared dataset state.

Changes:

  • Adds mod_gil_not_used declarations and Python 3.14 metadata.
  • Adds dataset locking, safe cache returns, and explicit move operations.
  • Adds concurrency regression tests and raises the pybind11 minimum version.
File summaries
File Description
csrc/faster_eval_api/coco_eval/dataset.cpp Implements synchronization and move behavior.
csrc/faster_eval_api/coco_eval/dataset.h Defines mutex-backed dataset API.
csrc/faster_eval_api/faster_eval_api.cpp Declares free-threading support.
csrc/mask_api/mask_api.cpp Declares free-threading support.
pyproject.toml Raises pybind11 build requirement.
setup.py Adds Python 3.14 classifier.
tests/test_cpp_safety_regressions.py Adds concurrent cache tests.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp
Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp Outdated
Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp Outdated
Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp Outdated
Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp Outdated
Comment thread tests/test_cpp_safety_regressions.py Outdated
@Borda
Borda marked this pull request as draft August 24, 2026 16:15
Changes:
- Refactor native Dataset reads, parsing, cleaning, and tuple loading to snapshot or swap state under the mutex while performing Python conversion and reference destruction after unlock; invalidate per-key parsed cache entries and version cache publication after append.
- Remove unused Dataset move assignment while retaining move construction for pickle restore.
- Add spawned-process, barrier-synchronized regressions for finalizer and conversion re-entry, concurrent cache publication, append invalidation, and public pickle round trips.
- Add a non-publishing CPython 3.14t CI job that asserts no-GIL mode, imports both extensions with warnings as errors, installs from the sdist, and runs focused Dataset concurrency tests.
- Correct the native free-threading comment to describe CPython warning and GIL re-enable behavior.

Impact:
- Prevents self-deadlocks and stale native annotation results when Python callbacks or writers interact with Dataset.
- Makes deadlock regressions externally bounded and exercises the pybind pickle move path.
- Adds executable CI coverage for the declared free-threaded compatibility without publishing artifacts.

Verification:
- Native extension build passed; 9 Dataset concurrency tests and 33 Dataset/safety tests passed.
- Full suite passed: 211 passed, 2 skipped.
- pre-commit run --all-files passed, including Ruff, docformatter, mdformat, and clang-format; git diff --check passed.

Residual limits:
- The hosted CPython 3.14t job and TSan have not executed; local runtime tests used a GIL-enabled interpreter.
- Independent QA/challenger provenance remains unverified because current host events do not satisfy the installed validator.

---

Co-authored-by: Codex <codex@openai.com>
@Borda
Borda requested a balanced review from Copilot August 24, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Move assignment is missing, the promised single-lock sweep is absent, and merged annotations incur an avoidable extra copy.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

csrc/faster_eval_api/coco_eval/dataset.cpp:299

  • This makes the returned vector const and then copies every InstanceAnnotation into merged. Since get_cpp_annotations now returns an owned vector, this branch can still move its elements as before; otherwise the API change adds a second O(number of annotations) copy for every merged category sweep.
                                const std::vector<InstanceAnnotation> anns =
                                    get_cpp_annotations(img_id, cat_id);
                                merged.insert(merged.end(), anns.begin(),
                                              anns.end());
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread csrc/faster_eval_api/coco_eval/dataset.h
Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp
@Borda Borda changed the title feat(build): declare free-threading support Support free-threaded CPython in native extensions Aug 24, 2026
Changes:
- Extend standard sdist CI through Python 3.14 and run the free-threaded job on both 3.13t and 3.14t.
- Verify native modules load from the installed sdist outside the checkout and do not re-enable the GIL.
- Remove the unrelated Python 3.14 classifier and evaluator-source formatting churn while preserving the minimal mod_gil_not_used declaration.

Impact:
- Separate ordinary Python 3.14 compatibility failures from no-GIL-specific failures across the hosted platforms.
- Prevent checkout shadowing from producing false-positive free-threaded results and keep the PR review focused on required runtime safety.

Verification:
- Final workflow passed check-yaml; the staged diff passed git diff --cached --check.
- CPython 3.14.2t kept the GIL disabled after both native imports and passed 16 safety regressions.
- CPython 3.10.11 full suite passed 211 tests with 2 skips.

Residual limits:
- Hosted standard 3.14 and free-threaded 3.13t/3.14t jobs have not run on this local revision.
- The paste-ready PR description remains an ignored local artifact under .plans/active.

---

Co-authored-by: Codex <codex@openai.com>
@Borda
Borda requested a balanced review from Copilot August 24, 2026 20:26
@Borda
Borda marked this pull request as ready for review August 24, 2026 20:26
@Borda
Borda requested a review from MiXaiLL76 August 24, 2026 20:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The pybind11 cap excludes Python 3.14 support, and free-threaded CI does not exercise concurrent evaluator or mask operations.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread pyproject.toml Outdated
Comment thread .github/workflows/unittest.yml Outdated
Borda and others added 2 commits August 24, 2026 22:34
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Changes:
- Add a subprocess-timeout regression that releases evaluator and mask operations concurrently and asserts their concrete outputs.
- Run that regression in the free-threaded native stress-test selection.

Impact:
- A native deadlock in concurrent evaluator or mask entry points now fails within the existing 15-second process bound instead of hanging CI.
- CPython 3.13t/3.14t CI exercises both no-GIL module surfaces, not only Dataset cache contention.

Verification:
- pytest -p no:rerunfailures -q tests/test_cpp_safety_regressions.py: 17 passed.
- pre-commit run --files .github/workflows/unittest.yml tests/test_cpp_safety_regressions.py: passed.
- git diff --check: passed.

Residual limits:
- Hosted CPython 3.13t/3.14t CI remains required for direct free-threaded runtime validation.

---

Co-authored-by: Codex <codex@openai.com>

@espressolee espressolee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I independently validated exact base 123cc73a1da42420d77e230c9d02cba8b4d83ac3 against exact head ae7242631b374075c6c7f2a134a8c79f9e18095a on macOS arm64 with CPython 3.14.6 and 3.14.6t.

The primary compatibility result checks out: the base extension re-enables the GIL on 3.14t, while this head imports both native modules with the GIL still disabled. The head passed tests/test_cpp_safety_regressions.py (17/17) and the full local suite (177 passed, 28 skipped) on both regular and free-threaded 3.14.6. As positive controls, the two new stale-cache/append-invalidation tests fail against the base and pass against the head; the exact-head hosted matrix is also green.

I found one blocking liveness regression in the cache-publication retry. annotation_version is global to the dataset, so an append to an unrelated key invalidates the snapshot for the key currently being converted. Because conversion intentionally runs arbitrary Python hooks outside the mutex, a conversion hook that appends to a different key can invalidate every attempt and make get_cpp_annotations() retry forever.

A subprocess reproducer against the exact head timed out in 5/5 runs on both 3.14.6 and 3.14.6t. The same scenario returned in 5/5 runs on the base, and a one-shot mutation control returned in 5/5 runs on the head, so this is repeated global invalidation rather than ordinary conversion latency. Minimal shape:

dataset = _eval.Dataset()

class Area:
    def __float__(self):
        dataset.append_ref(
            999, 1, {"id": 2, "area": 1.0, "iscrowd": 0}
        )
        return 10.0

dataset.append_ref(
    1, 1, {"id": 1, "area": Area(), "iscrowd": 0}
)
dataset.get_cpp_annotations(1, 1)  # does not return at this head

The retry is at csrc/faster_eval_api/coco_eval/dataset.cpp:250: it compares the snapshot against a dataset-wide generation that every append_ref() increments.

A scratch proof using a global replacement epoch for clean/load_tuple plus per-key append versions makes both the repeated-hook reproducer and the one-shot control return, while retaining 17/17 focused tests and 177 passed / 28 skipped in both interpreter modes. An equivalent target-key identity/version check would also address the issue.

Continuous mutation of the requested key may reasonably require retry/abstention semantics; mutation of an unrelated key should not be able to starve this read. My local scope was macOS arm64; the PR's exact-head hosted cross-platform checks are green.


- name: Run synchronized native stress regressions
working-directory: ./tests
run: python -m pytest -q test_cpp_safety_regressions.py -k "TestDatasetConcurrency or test_concurrent_evaluator_and_mask_operations"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
run: python -m pytest -q test_cpp_safety_regressions.py -k "TestDatasetConcurrency or test_concurrent_evaluator_and_mask_operations"
run: python -m pytest -q test_cpp_safety_regressions.py -k "TestDatasetConcurrency"

Comment thread pyproject.toml
"Cython",
"pybind11>=2.12.0, <3",
# 3.0.0 is the first release supporting CPython 3.14 and 3.14t.
"pybind11>=3.0.0, <4",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
"pybind11>=3.0.0, <4",
"pybind11>=2.13.0; python_version<'3.14'",
"pybind11>=3.0.0; python_version>='3.14'"

for (const auto& kv : annotations) {
auto key = kv.first;
auto ann_list = kv.second;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
const auto& key = kv.first;
const auto& ann_list = kv.second;

@MiXaiLL76

Copy link
Copy Markdown
Owner

This branch has not been deployed

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants