From a1946c5f6e1cee2dbac714d6a98e8eac47a19161 Mon Sep 17 00:00:00 2001 From: Qubitium Date: Sat, 22 Aug 2026 11:22:31 +0800 Subject: [PATCH 1/5] fix: harden free-threaded execution state Serialize shared executor submission against replacement and synchronize Python-layer configuration snapshots and pattern thread modes. Harden native group-index/replacement handling and add deterministic GIL=0 regression coverage. Co-authored-by: omnigent --- pcre/pcre.py | 103 ++++++++++++++-------- pcre/threads.py | 161 +++++++++++++++++++++++----------- pcre_ext/pcre2.c | 21 ++++- tests/test_expand_fastpath.py | 29 ++++++ tests/test_gil_zero_safety.py | 77 +++++++++++++++- tests/test_threads.py | 61 ++++++++++++- 6 files changed, 358 insertions(+), 94 deletions(-) diff --git a/pcre/pcre.py b/pcre/pcre.py index 4109caf..c015c61 100644 --- a/pcre/pcre.py +++ b/pcre/pcre.py @@ -11,7 +11,7 @@ import warnings as _warnings from collections.abc import Iterable, Iterator, Mapping from functools import lru_cache -from threading import local +from threading import RLock, local from typing import Any, List import pcre_ext_c as _pcre2 @@ -59,6 +59,7 @@ get_auto_threshold, get_thread_default, get_thread_pool_size, + submit_thread_pool_tasks, threading_supported, ) @@ -73,6 +74,7 @@ _DEFAULT_JIT = True _DEFAULT_COMPAT_REGEX = False +_DEFAULT_CONFIG_LOCK = RLock() _DEFAULT_COMPILE_LOCAL = local() _LOCAL_CACHE_NAMES = ("cache", "flagged_cache") @@ -148,10 +150,18 @@ def _can_attach_match(raw: Any) -> bool: def _resolve_jit_setting(jit: bool | None) -> bool: if jit is None: - return _DEFAULT_JIT + with _DEFAULT_CONFIG_LOCK: + return bool(_DEFAULT_JIT) return bool(jit) +def _default_config_snapshot() -> tuple[bool, bool]: + """Read global compile defaults as one immutable configuration snapshot.""" + + with _DEFAULT_CONFIG_LOCK: + return bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX) + + def _extract_jit_override(flags: int) -> bool | None: override: bool | None = None if flags & JIT: @@ -302,19 +312,21 @@ class Pattern: """High-level wrapper around the C-backed :class:`pcre_ext_c.Pattern`.""" __slots__ = ( - "_pattern", "_groups_hint", - "_thread_mode", "_is_c_pattern", "_literal_findall", "_literal_findall_multi", "_literal_split", + "_pattern", + "_thread_mode", + "_thread_mode_lock", ) def __init__(self, pattern: _CPattern) -> None: self._pattern = pattern self._is_c_pattern = isinstance(pattern, _CPattern) self._thread_mode = _THREAD_MODE_DISABLED + self._thread_mode_lock = RLock() try: self._groups_hint = pattern.capture_count except AttributeError: # pragma: no cover - older extension fallback @@ -394,20 +406,24 @@ def groups(self) -> int: @property def thread_mode(self) -> str: - return self._thread_mode + with self._thread_mode_lock: + return self._thread_mode @property def use_threads(self) -> bool: - return self._thread_mode == _THREAD_MODE_ENABLED + return self.thread_mode == _THREAD_MODE_ENABLED def enable_threads(self) -> None: - self._thread_mode = _THREAD_MODE_ENABLED + with self._thread_mode_lock: + self._thread_mode = _THREAD_MODE_ENABLED def disable_threads(self) -> None: - self._thread_mode = _THREAD_MODE_DISABLED + with self._thread_mode_lock: + self._thread_mode = _THREAD_MODE_DISABLED def enable_auto_threads(self) -> None: - self._thread_mode = _THREAD_MODE_AUTO + with self._thread_mode_lock: + self._thread_mode = _THREAD_MODE_AUTO def _update_group_hint(self, match: Match) -> None: if self._groups_hint is not None: @@ -1054,7 +1070,7 @@ def parallel_map( options: int = 0, max_workers: int | None = None, ) -> List[Any]: - if self._thread_mode == _THREAD_MODE_DISABLED: + if self.thread_mode == _THREAD_MODE_DISABLED: raise RuntimeError( "Pattern not enabled for threaded execution; compile with Flag.THREADS " "or configure threading defaults." @@ -1145,11 +1161,12 @@ def _policy_wrapper(compiled: Pattern, thread_mode: str) -> Pattern: def _compile_default_builtin(pattern: str | bytes) -> Pattern: """Compile an exact built-in pattern through a per-thread direct cache.""" + default_jit, default_compat = _default_config_snapshot() thread_mode = _THREAD_MODE_AUTO if get_thread_default() else _THREAD_MODE_DISABLED return _compile_default_snapshot( pattern, - bool(_DEFAULT_JIT), - bool(_DEFAULT_COMPAT_REGEX), + default_jit, + default_compat, thread_mode, ) @@ -1223,6 +1240,8 @@ def _compile_flagged_builtin( def compile(pattern: Any, flags: FlagInput = 0) -> Pattern: + default_jit, default_compat = _default_config_snapshot() + # Fast path for the dominant shape: compile(pattern) with default flags. if flags == 0: if isinstance(pattern, Pattern): @@ -1239,13 +1258,13 @@ def compile(pattern: Any, flags: FlagInput = 0) -> Pattern: if type(pattern) in (str, bytes) and cached_compile is _ORIGINAL_CACHED_COMPILE: return _compile_default_builtin(pattern) - adjusted_pattern = _apply_regex_compat(pattern, bool(_DEFAULT_COMPAT_REGEX)) + adjusted_pattern = _apply_regex_compat(pattern, default_compat) if isinstance(adjusted_pattern, str): native_flags = _pcre2.PCRE2_UTF | _pcre2.PCRE2_UCP else: native_flags = 0 compiled = cached_compile( - adjusted_pattern, native_flags, Pattern, jit=_DEFAULT_JIT + adjusted_pattern, native_flags, Pattern, jit=default_jit ) thread_mode = ( _THREAD_MODE_AUTO if get_thread_default() else _THREAD_MODE_DISABLED @@ -1268,8 +1287,8 @@ def compile(pattern: Any, flags: FlagInput = 0) -> Pattern: return _compile_flagged_builtin( pattern, resolved_stdlib_flags, - bool(_DEFAULT_JIT), - bool(_DEFAULT_COMPAT_REGEX), + default_jit, + default_compat, thread_mode, ) @@ -1285,7 +1304,7 @@ def compile(pattern: Any, flags: FlagInput = 0) -> Pattern: ) jit_override = _extract_jit_override(resolved_flags_no_thread_markers) resolved_jit = _resolve_jit_setting(jit_override) - compat_enabled = bool(_DEFAULT_COMPAT_REGEX or compat_requested) + compat_enabled = bool(default_compat or compat_requested) if threads_requested: thread_mode = _THREAD_MODE_ENABLED @@ -1411,10 +1430,11 @@ def _module_cache_size() -> int: def _module_compile(pattern: Any, flags: FlagInput) -> Pattern: if type(pattern) in (str, bytes) and type(flags) in (int, Flag) and flags == 0: + default_jit, default_compat = _default_config_snapshot() thread_mode = ( _THREAD_MODE_AUTO if get_thread_default() else _THREAD_MODE_DISABLED ) - key = (pattern, bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX), thread_mode) + key = (pattern, default_jit, default_compat, thread_mode) if ( getattr(_DEFAULT_COMPILE_LOCAL, "epoch", -1) == get_cache_epoch() and getattr(_DEFAULT_COMPILE_LOCAL, "module_hot_key", None) == key @@ -1422,8 +1442,8 @@ def _module_compile(pattern: Any, flags: FlagInput) -> Pattern: return _DEFAULT_COMPILE_LOCAL.module_hot_value compiled = _cached_module_pattern( pattern, - _DEFAULT_JIT, - _DEFAULT_COMPAT_REGEX, + default_jit, + default_compat, thread_mode, ) if _DEFAULT_COMPILE_LOCAL.effective_limit > 0 and cache_input_allowed(pattern): @@ -1678,7 +1698,10 @@ def parallel_map( for subject in materials ] - executor = ensure_thread_pool(max_workers) + # Preserve eager pool creation and the established instrumentation seam; + # the submission helper below still revalidates the live pool while holding + # the lifecycle lock, so this return value is never used after the lease. + ensure_thread_pool(max_workers) # For the common default lookup shape, the C backend can execute directly # without rebuilding the Python wrapper's keyword arguments for every @@ -1710,6 +1733,9 @@ def parallel_map( # calls are already independent and release the interpreter lock for long # subjects. Batches preserve input order and retain the same exception # propagation behavior as the one-Future implementation. + # Pool acquisition and all submissions are one lock-scoped operation. A + # concurrent configure_thread_pool() may replace the pool only after this + # batch has been accepted by the executor. worker_count = max(1, get_thread_pool_size()) task_count = min(len(materials), worker_count * 2) chunk_size = (len(materials) + task_count - 1) // task_count @@ -1727,10 +1753,14 @@ def _run_chunk(start: int, stop: int) -> list[Any]: for index in range(start, stop) ] - futures = [ - executor.submit(_run_chunk, start, min(start + chunk_size, len(materials))) + def _make_task(start: int, stop: int) -> Any: + return lambda: _run_chunk(start, stop) + + tasks = [ + _make_task(start, min(start + chunk_size, len(materials))) for start in range(0, len(materials), chunk_size) ] + futures, _ = submit_thread_pool_tasks(tasks, max_workers=max_workers) results: List[Any] = [] for future in futures: results.extend(future.result()) @@ -1746,22 +1776,23 @@ def configure(*, jit: bool | None = None, compat_regex: bool | None = None) -> b global _DEFAULT_JIT, _DEFAULT_COMPAT_REGEX - if compat_regex is not None: - _DEFAULT_COMPAT_REGEX = bool(compat_regex) + with _DEFAULT_CONFIG_LOCK: + if compat_regex is not None: + _DEFAULT_COMPAT_REGEX = bool(compat_regex) - if jit is None: + if jit is None: + try: + _DEFAULT_JIT = bool(_pcre2.configure()) + except AttributeError: # pragma: no cover - legacy backend without helper + pass + return bool(_DEFAULT_JIT) + + new_value = bool(jit) try: - _DEFAULT_JIT = bool(_pcre2.configure()) + _DEFAULT_JIT = bool(_pcre2.configure(jit=new_value)) except AttributeError: # pragma: no cover - legacy backend without helper - pass - return _DEFAULT_JIT - - new_value = bool(jit) - try: - _DEFAULT_JIT = bool(_pcre2.configure(jit=new_value)) - except AttributeError: # pragma: no cover - legacy backend without helper - _DEFAULT_JIT = new_value - return _DEFAULT_JIT + _DEFAULT_JIT = new_value + return bool(_DEFAULT_JIT) def clear_cache() -> None: diff --git a/pcre/threads.py b/pcre/threads.py index 7a18977..e857cd6 100644 --- a/pcre/threads.py +++ b/pcre/threads.py @@ -12,10 +12,11 @@ import subprocess import sys import threading +from collections.abc import Callable, Iterator from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from typing import Final - _POOL_NAME: Final[str] = "pcre-worker" _MIN_CORES_FOR_THREADS: Final[int] = 8 _THREAD_POOL_LOCK = threading.RLock() @@ -39,21 +40,22 @@ def _performance_cpu_total() -> int: """Return macOS performance-tier logical CPUs when the kernel exposes it.""" global _PERFORMANCE_CPU_TOTAL - if _PERFORMANCE_CPU_TOTAL is not None: + with _THREAD_POOL_LOCK: + if _PERFORMANCE_CPU_TOTAL is not None: + return _PERFORMANCE_CPU_TOTAL + if sys.platform != "darwin": + _PERFORMANCE_CPU_TOTAL = 0 + return 0 + try: + value = subprocess.check_output( + ["sysctl", "-n", "hw.perflevel0.logicalcpu"], + text=True, + stderr=subprocess.DEVNULL, + ) + _PERFORMANCE_CPU_TOTAL = max(0, int(value.strip())) + except (OSError, ValueError, subprocess.CalledProcessError): + _PERFORMANCE_CPU_TOTAL = 0 return _PERFORMANCE_CPU_TOTAL - if sys.platform != "darwin": - _PERFORMANCE_CPU_TOTAL = 0 - return 0 - try: - value = subprocess.check_output( - ["sysctl", "-n", "hw.perflevel0.logicalcpu"], - text=True, - stderr=subprocess.DEVNULL, - ) - _PERFORMANCE_CPU_TOTAL = max(0, int(value.strip())) - except (OSError, ValueError, subprocess.CalledProcessError): - _PERFORMANCE_CPU_TOTAL = 0 - return _PERFORMANCE_CPU_TOTAL _THREADS_DEFAULT: bool = threading_supported() and not ( @@ -93,29 +95,77 @@ def _determine_worker_count(value: int | None) -> int: return resolved -def ensure_thread_pool(max_workers: int | None = None) -> ThreadPoolExecutor: - """Return the shared executor, creating or resizing it if required.""" +def _pool_for_target_locked( + target: int, +) -> tuple[ThreadPoolExecutor, ThreadPoolExecutor | None]: + """Return a pool for *target* while holding ``_THREAD_POOL_LOCK``.""" global _THREAD_POOL global _THREAD_POOL_WORKERS - target = _determine_worker_count( - max_workers if max_workers is not None else _THREAD_POOL_WORKERS + if _THREAD_POOL is not None and _THREAD_POOL_WORKERS == target: + return _THREAD_POOL, None + + old_pool = _THREAD_POOL + _THREAD_POOL = ThreadPoolExecutor( + max_workers=target, + thread_name_prefix=_POOL_NAME, ) + _THREAD_POOL_WORKERS = target + return _THREAD_POOL, old_pool - with _THREAD_POOL_LOCK: - if _THREAD_POOL is not None and _THREAD_POOL_WORKERS == target: - return _THREAD_POOL - if _THREAD_POOL is not None: - _THREAD_POOL.shutdown(wait=True) +def ensure_thread_pool(max_workers: int | None = None) -> ThreadPoolExecutor: + """Return the shared executor, creating or resizing it if required.""" - _THREAD_POOL = ThreadPoolExecutor( - max_workers=target, - thread_name_prefix=_POOL_NAME, + with _THREAD_POOL_LOCK: + target = _determine_worker_count( + max_workers if max_workers is not None else _THREAD_POOL_WORKERS ) - _THREAD_POOL_WORKERS = target - return _THREAD_POOL + pool, old_pool = _pool_for_target_locked(target) + + # Do not wait for old workers while holding the state lock. In particular, + # this avoids making executor shutdown depend on callbacks that inspect the + # pool configuration. + if old_pool is not None: + old_pool.shutdown(wait=True) + return pool + + +@contextmanager +def _thread_pool_submission( + max_workers: int | None = None, +) -> Iterator[tuple[ThreadPoolExecutor, int]]: + """Lease the pool state while a caller submits a batch of work. + + The lease is deliberately limited to executor acquisition and submission. + Waiting for futures while holding the lock could deadlock if user work + re-enters pool configuration. Reconfiguration cannot shut down the leased + executor until the context exits, so every submission is accepted by a live + executor. + """ + + old_pool: ThreadPoolExecutor | None = None + try: + with _THREAD_POOL_LOCK: + target = _determine_worker_count( + max_workers if max_workers is not None else _THREAD_POOL_WORKERS + ) + pool, old_pool = _pool_for_target_locked(target) + yield pool, target + finally: + if old_pool is not None: + old_pool.shutdown(wait=True) + + +def submit_thread_pool_tasks( + tasks: list[Callable[[], object]], *, max_workers: int | None = None +) -> tuple[list[object], int]: + """Submit *tasks* atomically with respect to pool replacement.""" + + with _thread_pool_submission(max_workers) as (pool, workers): + futures = [pool.submit(task) for task in tasks] + return futures, workers def configure_thread_pool( @@ -133,9 +183,11 @@ def configure_thread_pool( with _THREAD_POOL_LOCK: _THREAD_POOL_WORKERS = workers - if _THREAD_POOL is not None: - _THREAD_POOL.shutdown(wait=True) - _THREAD_POOL = None + pool = _THREAD_POOL + _THREAD_POOL = None + + if pool is not None: + pool.shutdown(wait=True) if preload: ensure_thread_pool(workers) @@ -160,9 +212,10 @@ def get_thread_pool_size() -> int: """Return the current configured worker count (creating defaults if needed).""" global _THREAD_POOL_WORKERS - if _THREAD_POOL_WORKERS is None: - _THREAD_POOL_WORKERS = _determine_worker_count(None) - return _THREAD_POOL_WORKERS + with _THREAD_POOL_LOCK: + if _THREAD_POOL_WORKERS is None: + _THREAD_POOL_WORKERS = _determine_worker_count(None) + return _THREAD_POOL_WORKERS def configure_threads( @@ -173,27 +226,30 @@ def configure_threads( global _THREADS_DEFAULT global _THREAD_AUTO_THRESHOLD - if enabled is not None: - _THREADS_DEFAULT = bool(enabled) + with _THREAD_POOL_LOCK: + if enabled is not None: + _THREADS_DEFAULT = bool(enabled) - if threshold is not None: - try: - new_threshold = int(threshold) - except (TypeError, ValueError) as exc: # pragma: no cover - defensive - raise TypeError("threshold must be an int") from exc - if new_threshold < 0: - raise ValueError("threshold must be >= 0") - _THREAD_AUTO_THRESHOLD = new_threshold + if threshold is not None: + try: + new_threshold = int(threshold) + except (TypeError, ValueError) as exc: # pragma: no cover - defensive + raise TypeError("threshold must be an int") from exc + if new_threshold < 0: + raise ValueError("threshold must be >= 0") + _THREAD_AUTO_THRESHOLD = new_threshold - return _THREADS_DEFAULT + return _THREADS_DEFAULT def get_thread_default() -> bool: - return _THREADS_DEFAULT + with _THREAD_POOL_LOCK: + return _THREADS_DEFAULT def get_auto_threshold() -> int: - return _THREAD_AUTO_THRESHOLD + with _THREAD_POOL_LOCK: + return _THREAD_AUTO_THRESHOLD atexit.register(shutdown_thread_pool) @@ -201,11 +257,12 @@ def get_auto_threshold() -> int: __all__ = [ "configure_thread_pool", - "ensure_thread_pool", - "shutdown_thread_pool", - "get_thread_pool_size", "configure_threads", - "get_thread_default", + "ensure_thread_pool", "get_auto_threshold", + "get_thread_default", + "get_thread_pool_size", + "shutdown_thread_pool", + "submit_thread_pool_tasks", "threading_supported", ] diff --git a/pcre_ext/pcre2.c b/pcre_ext/pcre2.c index 9f2460e..7e050b6 100644 --- a/pcre_ext/pcre2.c +++ b/pcre_ext/pcre2.c @@ -683,16 +683,27 @@ Match_groupdict(MatchObject *self, PyObject *args, PyObject *kwargs) return NULL; } - PyObject *key, *value; - Py_ssize_t pos = 0; - while (PyDict_Next(self->pattern->groupindex, &pos, &key, &value)) { + /* ``groupindex`` is exposed as a read-only mapping, but taking a list + snapshot avoids relying on PyDict_Next borrowed-entry traversal when + this extension runs on a free-threaded interpreter. */ + PyObject *items = PyDict_Items(self->pattern->groupindex); + if (items == NULL) { + Py_DECREF(result); + return NULL; + } + Py_ssize_t item_count = PyList_GET_SIZE(items); + for (Py_ssize_t item_pos = 0; item_pos < item_count; ++item_pos) { + PyObject *item = PyList_GET_ITEM(items, item_pos); + PyObject *key = PyTuple_GET_ITEM(item, 0); Py_ssize_t index = 0; if (resolve_group_key(self, key, &index) < 0) { + Py_DECREF(items); Py_DECREF(result); return NULL; } PyObject *group_value = match_get_group_value(self, index); if (group_value == NULL) { + Py_DECREF(items); Py_DECREF(result); return NULL; } @@ -703,11 +714,13 @@ Match_groupdict(MatchObject *self, PyObject *args, PyObject *kwargs) } if (PyDict_SetItem(result, key, group_value) < 0) { Py_DECREF(group_value); + Py_DECREF(items); Py_DECREF(result); return NULL; } Py_DECREF(group_value); } + Py_DECREF(items); return result; } @@ -1647,7 +1660,7 @@ match_expand_multiple_tokens(MatchObject *self, int *handled) { *handled = 0; - MatchExpandToken references[MATCH_EXPAND_MAX_TOKENS]; + MatchExpandToken references[MATCH_EXPAND_MAX_TOKENS] = {{0}}; PyObject *groups[MATCH_EXPAND_MAX_TOKENS] = {NULL}; Py_ssize_t group_offsets[MATCH_EXPAND_MAX_TOKENS] = {0}; Py_ssize_t group_lengths[MATCH_EXPAND_MAX_TOKENS] = {0}; diff --git a/tests/test_expand_fastpath.py b/tests/test_expand_fastpath.py index 92caf65..c11f381 100644 --- a/tests/test_expand_fastpath.py +++ b/tests/test_expand_fastpath.py @@ -218,6 +218,35 @@ def test_nine_references_stay_on_compatibility_parser( assert match.expand(r"\1\2\3\4\5\6\7\8\9") is sentinel +@pytest.mark.parametrize( + ("pattern", "subject", "template"), + [ + (r"(a)", "a", r"\g<"), + (r"(a)", "a", r"\g<>"), + (r"(a)", "a", r"\g"), + (b"(a)", b"a", rb"\g<"), + (b"(a)", b"a", rb"\g<>"), + (b"(a)", b"a", rb"\g"), + ], +) +def test_malformed_replacement_tokens_fail_without_native_state_corruption( + pattern, subject, template +) -> None: + actual = pcre.compile(pattern).fullmatch(subject) + expected = re.fullmatch(pattern, subject) + assert actual is not None and expected is not None + + with pytest.raises((re.error, IndexError)) as expected_error: + expected.expand(template) + with pytest.raises(type(expected_error.value)): + actual.expand(template) + + # A failed bounded/native parse must leave the match usable for a later + # valid expansion, including on the free-threaded extension build. + valid_template = template[:0] + (r"\1" if isinstance(template, str) else rb"\1") + assert actual.expand(valid_template) == subject + + @pytest.mark.parametrize( ("pattern", "subject"), [ diff --git a/tests/test_gil_zero_safety.py b/tests/test_gil_zero_safety.py index 9b89614..6955db3 100644 --- a/tests/test_gil_zero_safety.py +++ b/tests/test_gil_zero_safety.py @@ -11,8 +11,10 @@ import sys import threading -import pcre import pytest + +import pcre +import pcre.pcre as core from pcre import Flag @@ -126,3 +128,76 @@ def worker() -> None: expected = [match.span() for match in pattern.finditer(subject)] assert not errors assert sorted(spans) == expected + + +def test_gil_zero_pattern_thread_mode_reads_are_coherent(_skip_without_gil_zero) -> None: + pattern = pcre.compile(r"a", flags=pcre.Flag.NO_THREADS) + modes = {"disabled", "enabled", "auto"} + errors: list[str] = [] + start = threading.Barrier(9) + + def writer() -> None: + try: + start.wait() + for _ in range(500): + pattern.enable_threads() + pattern.disable_threads() + pattern.enable_auto_threads() + except Exception as exc: + errors.append(str(exc)) + + def reader() -> None: + try: + start.wait() + for _ in range(2_000): + assert pattern.thread_mode in modes + assert isinstance(pattern.use_threads, bool) + except Exception as exc: + errors.append(str(exc)) + + threads = [threading.Thread(target=writer)] + [ + threading.Thread(target=reader) for _ in range(8) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert not errors + + +def test_gil_zero_defaults_snapshot_during_concurrent_compile(_skip_without_gil_zero) -> None: + original_jit = core._DEFAULT_JIT + original_compat = core._DEFAULT_COMPAT_REGEX + errors: list[str] = [] + lock = threading.Lock() + + def record_error(exc: Exception) -> None: + with lock: + errors.append(str(exc)) + + def reconfigure() -> None: + try: + for index in range(100): + pcre.configure(jit=index % 2 == 0, compat_regex=index % 2 == 1) + except Exception as exc: + record_error(exc) + + def compile_repeatedly() -> None: + try: + for _ in range(250): + compiled = pcre.compile(r"a") + assert compiled.match("a") is not None + except Exception as exc: + record_error(exc) + + try: + workers = [threading.Thread(target=reconfigure)] + [ + threading.Thread(target=compile_repeatedly) for _ in range(4) + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + assert not errors + finally: + pcre.configure(jit=original_jit, compat_regex=original_compat) diff --git a/tests/test_threads.py b/tests/test_threads.py index 3d6adb2..43f4b86 100644 --- a/tests/test_threads.py +++ b/tests/test_threads.py @@ -7,9 +7,12 @@ from __future__ import annotations -import pcre.threads as threads_mod +import threading + import pytest +import pcre.threads as threads_mod + def test_threading_supported_false_on_low_core_count( monkeypatch: pytest.MonkeyPatch, @@ -187,3 +190,59 @@ def test_ensure_thread_pool_resizes_existing_pool() -> None: threads_mod.shutdown_thread_pool(wait=True) threads_mod._THREAD_POOL = original_pool threads_mod._THREAD_POOL_WORKERS = original_workers + + +def test_pool_submission_cannot_race_executor_replacement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reconfiguration waits until a leased executor accepts its batch.""" + + original_pool = threads_mod._THREAD_POOL + original_workers = threads_mod._THREAD_POOL_WORKERS + entered_submit = threading.Event() + release_submit = threading.Event() + configured = threading.Event() + original_submit = threads_mod.ThreadPoolExecutor.submit + + def gated_submit(executor, fn, *args, **kwargs): + entered_submit.set() + assert release_submit.wait(timeout=5) + return original_submit(executor, fn, *args, **kwargs) + + monkeypatch.setattr(threads_mod.ThreadPoolExecutor, "submit", gated_submit) + try: + threads_mod.shutdown_thread_pool(wait=True) + threads_mod.configure_thread_pool(max_workers=1, preload=True) + + submitted: list[object] = [] + + def submitter() -> None: + futures, _ = threads_mod.submit_thread_pool_tasks( + [lambda: "accepted"], max_workers=1 + ) + submitted.extend(futures) + + def reconfigure() -> None: + threads_mod.configure_thread_pool(max_workers=2, preload=False) + configured.set() + + submit_thread = threading.Thread(target=submitter) + submit_thread.start() + assert entered_submit.wait(timeout=5) + + configure_thread = threading.Thread(target=reconfigure) + configure_thread.start() + assert not configured.wait(timeout=0.05) + + release_submit.set() + submit_thread.join(timeout=5) + configure_thread.join(timeout=5) + assert not submit_thread.is_alive() + assert not configure_thread.is_alive() + assert configured.is_set() + assert submitted[0].result(timeout=5) == "accepted" # type: ignore[attr-defined] + finally: + release_submit.set() + threads_mod.shutdown_thread_pool(wait=True) + threads_mod._THREAD_POOL = original_pool + threads_mod._THREAD_POOL_WORKERS = original_workers From 59d742fd5dc66cd9e50cbdaeee76384737aa1220 Mon Sep 17 00:00:00 2001 From: Qubitium Date: Sat, 22 Aug 2026 11:51:47 +0800 Subject: [PATCH 2/5] perf: publish free-threaded config snapshots Remove avoidable read-side locks from compile and thread configuration paths while preserving coherent writer publication and executor submission leases. Co-authored-by: omnigent --- pcre/pcre.py | 28 +++++++++++++++-------- pcre/threads.py | 38 +++++++++++++++++++++---------- tests/test_core.py | 2 ++ tests/test_gil_zero_safety.py | 42 +++++++++++++++++++++++++++++++++++ tests/test_jit.py | 9 ++++++-- 5 files changed, 96 insertions(+), 23 deletions(-) diff --git a/pcre/pcre.py b/pcre/pcre.py index c015c61..f6c3f4f 100644 --- a/pcre/pcre.py +++ b/pcre/pcre.py @@ -74,6 +74,12 @@ _DEFAULT_JIT = True _DEFAULT_COMPAT_REGEX = False +# Compile defaults are published as one immutable tuple. A module-global +# reference load is the hot-path read; configure() serializes writers and +# publishes the tuple after updating the legacy globals. The legacy globals +# remain as a compatibility seam for tests and existing internal users that +# monkeypatch them directly. +_DEFAULT_CONFIG: tuple[bool, bool] = (True, False) _DEFAULT_CONFIG_LOCK = RLock() _DEFAULT_COMPILE_LOCAL = local() _LOCAL_CACHE_NAMES = ("cache", "flagged_cache") @@ -150,16 +156,14 @@ def _can_attach_match(raw: Any) -> bool: def _resolve_jit_setting(jit: bool | None) -> bool: if jit is None: - with _DEFAULT_CONFIG_LOCK: - return bool(_DEFAULT_JIT) + return _default_config_snapshot()[0] return bool(jit) def _default_config_snapshot() -> tuple[bool, bool]: - """Read global compile defaults as one immutable configuration snapshot.""" + """Read the atomically published compile-default snapshot.""" - with _DEFAULT_CONFIG_LOCK: - return bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX) + return _DEFAULT_CONFIG def _extract_jit_override(flags: int) -> bool | None: @@ -177,6 +181,7 @@ def _extract_jit_override(flags: int) -> bool | None: _DEFAULT_JIT = bool(_pcre2.configure()) except AttributeError: # pragma: no cover - legacy backend without configure helper _DEFAULT_JIT = True +_DEFAULT_CONFIG = (bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX)) _STD_RE_FLAG_MAP: dict[_std_re.RegexFlag, int] = { _std_re.RegexFlag.IGNORECASE: _pcre2.PCRE2_CASELESS, @@ -406,12 +411,15 @@ def groups(self) -> int: @property def thread_mode(self) -> str: - with self._thread_mode_lock: - return self._thread_mode + # ``_thread_mode`` is always one of the immutable module constants. + # Free-threaded CPython publishes this single object reference without + # torn reads; writers still serialize transitions for last-writer + # semantics without putting a lock on every match/search call. + return self._thread_mode @property def use_threads(self) -> bool: - return self.thread_mode == _THREAD_MODE_ENABLED + return self._thread_mode == _THREAD_MODE_ENABLED def enable_threads(self) -> None: with self._thread_mode_lock: @@ -1774,7 +1782,7 @@ def configure(*, jit: bool | None = None, compat_regex: bool | None = None) -> b ``compat_regex`` to change the default behaviour for :data:`Flag.COMPAT_UNICODE_ESCAPE`. """ - global _DEFAULT_JIT, _DEFAULT_COMPAT_REGEX + global _DEFAULT_CONFIG, _DEFAULT_JIT, _DEFAULT_COMPAT_REGEX with _DEFAULT_CONFIG_LOCK: if compat_regex is not None: @@ -1785,6 +1793,7 @@ def configure(*, jit: bool | None = None, compat_regex: bool | None = None) -> b _DEFAULT_JIT = bool(_pcre2.configure()) except AttributeError: # pragma: no cover - legacy backend without helper pass + _DEFAULT_CONFIG = (bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX)) return bool(_DEFAULT_JIT) new_value = bool(jit) @@ -1792,6 +1801,7 @@ def configure(*, jit: bool | None = None, compat_regex: bool | None = None) -> b _DEFAULT_JIT = bool(_pcre2.configure(jit=new_value)) except AttributeError: # pragma: no cover - legacy backend without helper _DEFAULT_JIT = new_value + _DEFAULT_CONFIG = (bool(_DEFAULT_JIT), bool(_DEFAULT_COMPAT_REGEX)) return bool(_DEFAULT_JIT) diff --git a/pcre/threads.py b/pcre/threads.py index e857cd6..1f7a54a 100644 --- a/pcre/threads.py +++ b/pcre/threads.py @@ -22,7 +22,6 @@ _THREAD_POOL_LOCK = threading.RLock() _THREAD_POOL: ThreadPoolExecutor | None = None _THREAD_POOL_WORKERS: int | None = None -_THREAD_AUTO_THRESHOLD: int = 60_000 _PERFORMANCE_CPU_TOTAL: int | None = None @@ -61,6 +60,12 @@ def _performance_cpu_total() -> int: _THREADS_DEFAULT: bool = threading_supported() and not ( hasattr(sys, "_is_gil_enabled") and sys._is_gil_enabled() ) +_THREAD_AUTO_THRESHOLD: int = 60_000 +# Configuration readers use one immutable publication instead of taking the +# lifecycle lock on every compile. Writers update the legacy globals and this +# tuple while holding _THREAD_POOL_LOCK. +_THREAD_CONFIG: tuple[bool, int] = (_THREADS_DEFAULT, _THREAD_AUTO_THRESHOLD) +_THREAD_POOL_CONFIG: int | None = None def _max_threads() -> int: @@ -102,8 +107,10 @@ def _pool_for_target_locked( global _THREAD_POOL global _THREAD_POOL_WORKERS + global _THREAD_POOL_CONFIG if _THREAD_POOL is not None and _THREAD_POOL_WORKERS == target: + _THREAD_POOL_CONFIG = target return _THREAD_POOL, None old_pool = _THREAD_POOL @@ -112,6 +119,7 @@ def _pool_for_target_locked( thread_name_prefix=_POOL_NAME, ) _THREAD_POOL_WORKERS = target + _THREAD_POOL_CONFIG = target return _THREAD_POOL, old_pool @@ -178,11 +186,13 @@ def configure_thread_pool( global _THREAD_POOL global _THREAD_POOL_WORKERS + global _THREAD_POOL_CONFIG workers = _determine_worker_count(max_workers) with _THREAD_POOL_LOCK: _THREAD_POOL_WORKERS = workers + _THREAD_POOL_CONFIG = workers pool = _THREAD_POOL _THREAD_POOL = None @@ -211,10 +221,15 @@ def shutdown_thread_pool(*, wait: bool = True) -> None: def get_thread_pool_size() -> int: """Return the current configured worker count (creating defaults if needed).""" - global _THREAD_POOL_WORKERS + global _THREAD_POOL_CONFIG, _THREAD_POOL_WORKERS + snapshot = _THREAD_POOL_CONFIG + if snapshot is not None and snapshot == _THREAD_POOL_WORKERS: + return snapshot + with _THREAD_POOL_LOCK: if _THREAD_POOL_WORKERS is None: _THREAD_POOL_WORKERS = _determine_worker_count(None) + _THREAD_POOL_CONFIG = _THREAD_POOL_WORKERS return _THREAD_POOL_WORKERS @@ -223,12 +238,11 @@ def configure_threads( ) -> bool: """Adjust the global threading defaults and/or auto threshold.""" - global _THREADS_DEFAULT - global _THREAD_AUTO_THRESHOLD + global _THREAD_CONFIG, _THREADS_DEFAULT, _THREAD_AUTO_THRESHOLD with _THREAD_POOL_LOCK: - if enabled is not None: - _THREADS_DEFAULT = bool(enabled) + new_enabled = _THREADS_DEFAULT if enabled is None else bool(enabled) + new_threshold = _THREAD_AUTO_THRESHOLD if threshold is not None: try: @@ -237,19 +251,19 @@ def configure_threads( raise TypeError("threshold must be an int") from exc if new_threshold < 0: raise ValueError("threshold must be >= 0") - _THREAD_AUTO_THRESHOLD = new_threshold - return _THREADS_DEFAULT + _THREADS_DEFAULT = new_enabled + _THREAD_AUTO_THRESHOLD = new_threshold + _THREAD_CONFIG = (new_enabled, new_threshold) + return new_enabled def get_thread_default() -> bool: - with _THREAD_POOL_LOCK: - return _THREADS_DEFAULT + return _THREAD_CONFIG[0] def get_auto_threshold() -> int: - with _THREAD_POOL_LOCK: - return _THREAD_AUTO_THRESHOLD + return _THREAD_CONFIG[1] atexit.register(shutdown_thread_pool) diff --git a/tests/test_core.py b/tests/test_core.py index 47a733e..4b2da97 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,6 +3,7 @@ from collections import OrderedDict import pytest + from pcre import Flag from pcre import cache as cache_mod from pcre import pcre as core @@ -253,6 +254,7 @@ def fake_cached(pattern_value, flags, wrapper, *, jit): monkeypatch.setattr(core, "cached_compile", fake_cached) monkeypatch.setattr(core, "_DEFAULT_COMPAT_REGEX", True) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (core._DEFAULT_JIT, True)) compiled = core.compile("\\u0041") diff --git a/tests/test_gil_zero_safety.py b/tests/test_gil_zero_safety.py index 6955db3..4340ca1 100644 --- a/tests/test_gil_zero_safety.py +++ b/tests/test_gil_zero_safety.py @@ -201,3 +201,45 @@ def compile_repeatedly() -> None: assert not errors finally: pcre.configure(jit=original_jit, compat_regex=original_compat) + + +def test_gil_zero_default_snapshot_never_contains_mixed_defaults( + _skip_without_gil_zero, +) -> None: + original_jit = core._DEFAULT_JIT + original_compat = core._DEFAULT_COMPAT_REGEX + expected = {(True, False), (False, True)} + snapshots: list[tuple[bool, bool]] = [] + errors: list[str] = [] + start = threading.Barrier(9) + + def reconfigure() -> None: + try: + start.wait() + for index in range(500): + pcre.configure(jit=index % 2 == 0, compat_regex=index % 2 == 1) + except Exception as exc: + errors.append(str(exc)) + + def read_snapshot() -> None: + try: + start.wait() + for _ in range(5_000): + snapshot = core._default_config_snapshot() + assert snapshot in expected + snapshots.append(snapshot) + except Exception as exc: + errors.append(str(exc)) + + try: + workers = [threading.Thread(target=reconfigure)] + [ + threading.Thread(target=read_snapshot) for _ in range(8) + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + assert snapshots + assert not errors + finally: + pcre.configure(jit=original_jit, compat_regex=original_compat) diff --git a/tests/test_jit.py b/tests/test_jit.py index 7703a5b..212f189 100644 --- a/tests/test_jit.py +++ b/tests/test_jit.py @@ -3,12 +3,12 @@ import types -import pcre import pytest + +import pcre from pcre import Flag from pcre import pcre as core - _BAD_OPTION_SENTINEL = 0xFFFFFFFF @@ -40,6 +40,7 @@ def fake_cached(pattern, flags, wrapper, *, jit): monkeypatch.setattr(core, "cached_compile", fake_cached) monkeypatch.setattr(core, "_DEFAULT_JIT", False) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (False, core._DEFAULT_COMPAT_REGEX)) compiled = pcre.compile("expr", flags=Flag.JIT) @@ -57,6 +58,7 @@ def fake_cached(pattern, flags, wrapper, *, jit): monkeypatch.setattr(core, "cached_compile", fake_cached) monkeypatch.setattr(core, "_DEFAULT_JIT", True) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (True, core._DEFAULT_COMPAT_REGEX)) compiled = pcre.compile("expr", flags=Flag.NO_JIT) @@ -81,6 +83,7 @@ def fake_configure(*, jit=None): monkeypatch.setattr(core._pcre2, "configure", fake_configure) monkeypatch.setattr(core, "_DEFAULT_JIT", True) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (True, core._DEFAULT_COMPAT_REGEX)) assert pcre.configure(jit=False) is False assert core._DEFAULT_JIT is False @@ -107,6 +110,7 @@ def fake_configure(*, jit=None): monkeypatch.setattr(core, "cached_compile", fake_cached) monkeypatch.setattr(core, "_DEFAULT_JIT", True) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (True, core._DEFAULT_COMPAT_REGEX)) monkeypatch.setattr(core._pcre2, "configure", fake_configure) pcre.configure(jit=False) @@ -146,6 +150,7 @@ def fake_cached(pattern, flags, wrapper, *, jit): monkeypatch.setattr(core, "cached_compile", fake_cached) monkeypatch.setattr(core, "_DEFAULT_JIT", True) + monkeypatch.setattr(core, "_DEFAULT_CONFIG", (True, core._DEFAULT_COMPAT_REGEX)) first = pcre.compile("expr") second = pcre.compile("expr", flags=Flag.NO_JIT) From 3f8025eeea203a1f189aa8fffb31bbef236db26a Mon Sep 17 00:00:00 2001 From: Qubitium Date: Sat, 22 Aug 2026 11:53:03 +0800 Subject: [PATCH 3/5] docs: correct free-threaded benchmark results Document the controlled Apple arm64 A/B matrix, reduced lock overhead, and the limits of taskpolicy scheduling measurements. Co-authored-by: omnigent --- README.md | 98 +++++++++++++++++++++---------------------------------- 1 file changed, 38 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 41ae467..cd64d0a 100644 --- a/README.md +++ b/README.md @@ -66,70 +66,48 @@ PyPcre pairs Python's familiar `re`-compatible API with the real `PCRE2` engine. ### Benchmark Highlights 🏁 -#### API hot paths and 12-core fan-out - -Pinned A/B measurements on an Apple M4 Max use the same `taskpolicy -t 1 -l 1` -scheduler policy for both interpreters. The host reports 12 performance logical -CPUs and 4 efficiency logical CPUs; macOS does not provide an unprivileged hard -per-process CPU mask, so the benchmark records the topology rather than claiming -hard CPU affinity. - -| Workload | Python 3.10 | Python 3.14t/GIL=0 | -| --- | ---: | ---: | -| `parallel_map(search)`, 16 × 1 MiB subjects, 12 workers | **8.57x** | **7.85x** | -| `parallel_map(findall)`, 48 × 1 MiB subjects, 12 workers | **11.51x** | **11.25x** | -| No-op `escape("literal")` | **5.0x** | **3.6x** | -| No-op `escape(b"literal")` | **6.9x** | **6.5x** | -| Bound literal `sub(..., count=1)` | **4.7x** | **4.3x** | -| Bound numeric-reference `sub(..., count=1)` | **4.3x** | **4.1x** | -| Bound explicit-reference `sub(..., count=1)` | **4.6x** | **4.3x** | -| Bound named-reference `sub(..., count=1)` | **4.2x** | **4.3x** | -| Bound numeric-reference `sub(..., count=2)` | **4.6x** | **4.2x** | -| Bound numeric-reference `sub(..., count=4)` | **6.0x** | **5.1x** | -| Bound numeric-reference `sub(..., count=8)` | **8.6x** | **6.1x** | -| Cached compile with `re.I` | **6.7x** | **6.9x** | -| Cached compile with `re.I|re.M|re.S|re.X` | **6.2x** | **6.4x** | -| First-read `lastindex` cost, sole capture | **11.8x** | **8.2x** | -| Deprecated `template()` compatibility call | **6.2x** | **1.1x** | -| Literal-capture `findall`, 100 matches | **5.0x** | **6.2x** | -| Literal-capture `findall`, 500 matches | **7.0x** | **8.4x** | -| Two literal captures `findall`, 100 matches | **7.5x** | **10.8x** | -| Two literal captures `findall`, 500 matches | **13.5x** | **18.3x** | -| Eight literal captures `findall`, 500 matches | **24.5x** | **29.4x** | -| Three literal captures `split`, 500 captures | **4.5x** | **6.5x** | -| Eight literal captures `split`, 500 captures | **6.6x** | **9.0x** | -| Literal-capture `split`, 100 captures | **2.6x** | **3.5x** | -| Literal-capture `split`, 2,000 captures | **3.1x** | **3.8x** | -| Bound one-character literal `Pattern.split` | **2.1x** | **1.7x** | -| Bound backreference `sub` hot path | **1.38 μs** | **1.14 μs** | -| One-match numeric-reference `Pattern.sub` | **0.45 μs** | **0.34 μs** | -| One-match explicit-reference `Pattern.sub` | **0.45 μs** | **0.31 μs** | -| One-match named-reference `Pattern.sub` | **0.46 μs** | **0.33 μs** | -| Repeated call-local `Match.groups()` | **~0.05 μs** | **~0.05 μs** | -| Call-local `Match.expand(r"[\\1]")` | **0.07 μs** | **0.07 μs** | -| Call-local `Match.expand(r"[\\g<1>]")` | **0.11 μs** | **0.08 μs** | -| Call-local `Match.expand(r"[\\g]")` | **0.13 μs** | **0.11 μs** | -| Call-local two-name `Match.expand` | **0.18 μs** | **0.15 μs** | -| Call-local three-name `Match.expand` | **0.23 μs** | **0.20 μs** | -| Call-local eight-name `Match.expand` | **0.44 μs** | **0.39 μs** | -| Literal-backslash + named `Match.expand` | **0.12 μs** | **0.10 μs** | -| Named + backslash-suffix `Match.expand` | **0.16 μs** | **0.13 μs** | -| Repeated default `compile("(x)")` | **0.49 μs** | **0.38 μs** | -| Repeated integer-flagged `compile("x", CASELESS)` | **1.16 μs** | **0.81 μs** | - -The parallel figures are serial-to-parallel speedups and preserve input order and -exception behavior. Large `findall` scans release the GIL only around the PCRE2 -call; match data, context, and subject ownership remain worker-local. Reproduce -the fan-out benchmark with: +#### Controlled API hot-path A/B + +The current comparison is origin/main `03f4d10c379babcc9208e45705d774cb93bcebb8` +versus the follow-up code commit +`59d742fd5dc66cd9e50cbdaeee76384737aa1220`. It was measured on an Apple M4 Max +with 12 performance and 4 efficiency logical CPUs, macOS 26.6, PCRE2 10.47, +and Apple clang 21. Python 3.10.19 is GIL-enabled; Python 3.14.0rc2 is the +resolved Apple arm64 free-threaded build at +`/Users/diego/.local/share/uv/python/cpython-3.14.0rc2+freethreaded-macos-aarch64-none/bin/python3.14t` +and reports `sys._is_gil_enabled() == False`. + +Each API value is the median of 9 repetitions of 10,000 calls. Cold compile is +the median of 9 repetitions of 1,000 unique compiles. Lower is better. + +| Workload | 3.10 origin | 3.10 follow-up | Δ | 3.14t origin | 3.14t follow-up | Δ | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Cached `compile(..., re.I|re.M|re.S|re.X)` | 0.672 μs | 0.700 μs | +4.2% | 0.413 μs | 0.422 μs | +2.2% | +| Module `search` | 0.592 μs | 0.645 μs | +9.0% | 0.473 μs | 0.479 μs | +1.3% | +| Cold compile | 6.849 μs | 7.191 μs | +5.0% | 4.747 μs | 5.099 μs | +7.4% | + +The earlier lock-heavy PR measurement for `a1946c5f6e1cee2dbac714d6a98e8eac47a19161` +reported cached-compile regressions of +83.6% (3.10) and +65.1% (3.14t), and +module-search regressions of +30.6% and +25.9%. The follow-up removes the +avoidable per-call configuration/thread-state locks and materially reduces those +regressions; it does not claim a speedup over origin. + +Reproduce the API comparison with the same scheduler-tier hint for both builds: ```bash -taskpolicy -t 1 -l 1 env PYTHONPATH=. \ - PYPCRE_PARALLEL_WORKERS=12 PYPCRE_PARALLEL_RUNS=3 \ - python3 benchmarks/parallel_map_hotpath.py +cd /Users/diego/tmp-omni-workspace/pypcre/.worktrees/gil0-fixes +PYPCRE_BENCH_RUNS=10000 PYPCRE_BENCH_REPEATS=9 \ + taskpolicy -t 1 -l 1 env PYTHONPATH=. \ + ./.venv-gil0/bin/python benchmarks/api_hotpaths.py ``` -The same script runs under Python 3.14t. The API microbenchmarks are available in -[`benchmarks/api_hotpaths.py`](benchmarks/api_hotpaths.py). +Run the same command from the origin checkout with its Python 3.10.19 or +3.14.0rc2t environment for the A/B side. + +`taskpolicy -t 1 -l 1` is not hard P-core affinity on this unprivileged macOS +host; no P-core-only speedup is claimed. The API microbenchmarks are available in +[`benchmarks/api_hotpaths.py`](benchmarks/api_hotpaths.py), and the free-threaded +results should not be generalized to GIL-enabled interpreters. Measured on a `Python 3.14.6` free-threaded build on x86_64 Linux with compiled-pattern reuse and JIT enabled. Times are the best of several runs; lower is better. Only workloads where PyPcre is decisively faster than both `stdlib.re` and `regex` are shown. From 4197a185a3ab826ed89b7698c61ca88253962308 Mon Sep 17 00:00:00 2001 From: Qubitium Date: Sat, 22 Aug 2026 12:16:54 +0800 Subject: [PATCH 4/5] docs: refresh stdlib and regex benchmark data Update the preserved comparison tables with complete Apple arm64 medians for stdlib.re and regex workloads. Co-authored-by: omnigent --- README.md | 65 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index cd64d0a..157b0d1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Fast, free-threaded Python bindings for `PCRE2` with a stable `stdlib.re`-compat ## Latest News 🚀 -* 08/09–08/10/2026 **API performance and safety update**: bounded call-local fast paths accelerate `findall`, `split`, `sub`/`subn`, `Match.expand`, `lastindex`, flag handling, `template()`, and `escape` by a representative **2x to 30x+**, while large ordered `parallel_map(search/findall)` workloads scale by **7.85x to 11.5x** across Python 3.10 and free-threaded Python 3.14t/GIL=0. Compatibility fallbacks preserve complex patterns, subclasses, buffers, and callables; caches remain thread-scoped and size-bounded, and no fast path retains subjects, results, or extra captured values. Unsafe UTF bytes compilation is also blocked, with differential, randomized, concurrency, subprocess, and memory-safety coverage. 🧵⚡🛡️ +* 08/09–08/10/2026 **API performance and safety update**: bounded call-local fast paths accelerate `findall`, `split`, `sub`/`subn`, `Match.expand`, `lastindex`, flag handling, `template()`, and `escape` by a representative **2x to 38x+**, while large ordered `parallel_map(search)` workloads measured **7.55x to 7.71x** on the Apple arm64 free-threaded comparison runs. Compatibility fallbacks preserve complex patterns, subclasses, buffers, and callables; caches remain thread-scoped and size-bounded, and no fast path retains subjects, results, or extra captured values. Unsafe UTF bytes compilation is also blocked, with differential, randomized, concurrency, subprocess, and memory-safety coverage. 🧵⚡🛡️ * 08/08/2026 **0.6.0**: `findall`, `finditer`, `sub`/`subn`, `split`, and `match`/`search`/`fullmatch` are now up to **46x faster** than `stdlib.re` and **48x faster** than `regex` on `finditer`/`findall` workloads, **13x** on `split`, and **2–9x** on `sub`/`subn` backref workloads, with full `re` semantics. Free-threaded `findall` reaches **13.8x** vs `re` on 8 threads. 🚀⚡ * 07/27/2026 [0.5.0](https://github.com/ModelCloud/PyPcre/releases/tag/v0.5.0): Zero-copy buffer-protocol subject support (`mmap.mmap`, `bytearray`, `array.array`) with UTF-8 validation and GIL=0-safe memory pinning. 🗂️⚡ * 07/24/2026 [0.4.0](https://github.com/ModelCloud/PyPcre/releases/tag/v0.4.0): C extension hardening (memory/pointer safety, bounds checks, atomic allocator init), GIL=0 safety verified, vectorized UTF-8 index/offset conversion, GIL-release threshold for small calls, C `findall` implementation, and README competitor benchmarks. 🛡️⚡ @@ -69,10 +69,10 @@ PyPcre pairs Python's familiar `re`-compatible API with the real `PCRE2` engine. #### Controlled API hot-path A/B The current comparison is origin/main `03f4d10c379babcc9208e45705d774cb93bcebb8` -versus the follow-up code commit -`59d742fd5dc66cd9e50cbdaeee76384737aa1220`. It was measured on an Apple M4 Max -with 12 performance and 4 efficiency logical CPUs, macOS 26.6, PCRE2 10.47, -and Apple clang 21. Python 3.10.19 is GIL-enabled; Python 3.14.0rc2 is the +versus the PR head `3f8025eeea203a1f189aa8fffb31bbef236db26a`. It was measured +on an Apple M4 Max with 12 performance and 4 efficiency logical CPUs, macOS +26.6, PCRE2 10.47, Apple clang 21, and `regex==2025.11.3`. Python 3.10.11 is +GIL-enabled; Python 3.14.0rc2 is the resolved Apple arm64 free-threaded build at `/Users/diego/.local/share/uv/python/cpython-3.14.0rc2+freethreaded-macos-aarch64-none/bin/python3.14t` and reports `sys._is_gil_enabled() == False`. @@ -82,8 +82,8 @@ the median of 9 repetitions of 1,000 unique compiles. Lower is better. | Workload | 3.10 origin | 3.10 follow-up | Δ | 3.14t origin | 3.14t follow-up | Δ | | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| Cached `compile(..., re.I|re.M|re.S|re.X)` | 0.672 μs | 0.700 μs | +4.2% | 0.413 μs | 0.422 μs | +2.2% | -| Module `search` | 0.592 μs | 0.645 μs | +9.0% | 0.473 μs | 0.479 μs | +1.3% | +| Cached `compile(..., re.I|re.M|re.S|re.X)` | 0.652 μs | 0.655 μs | +0.5% | 0.424 μs | 0.427 μs | +0.7% | +| Module `search` | 0.561 μs | 0.570 μs | +1.6% | 0.456 μs | 0.469 μs | +2.9% | | Cold compile | 6.849 μs | 7.191 μs | +5.0% | 4.747 μs | 5.099 μs | +7.4% | The earlier lock-heavy PR measurement for `a1946c5f6e1cee2dbac714d6a98e8eac47a19161` @@ -101,7 +101,7 @@ PYPCRE_BENCH_RUNS=10000 PYPCRE_BENCH_REPEATS=9 \ ./.venv-gil0/bin/python benchmarks/api_hotpaths.py ``` -Run the same command from the origin checkout with its Python 3.10.19 or +Run the same command from the origin checkout with its Python 3.10.11 or 3.14.0rc2t environment for the A/B side. `taskpolicy -t 1 -l 1` is not hard P-core affinity on this unprivileged macOS @@ -109,17 +109,22 @@ host; no P-core-only speedup is claimed. The API microbenchmarks are available i [`benchmarks/api_hotpaths.py`](benchmarks/api_hotpaths.py), and the free-threaded results should not be generalized to GIL-enabled interpreters. -Measured on a `Python 3.14.6` free-threaded build on x86_64 Linux with compiled-pattern reuse and JIT enabled. Times are the best of several runs; lower is better. Only workloads where PyPcre is decisively faster than both `stdlib.re` and `regex` are shown. +Measured on the Apple arm64 matrix above with compiled-pattern reuse and JIT enabled. Values are medians of three outer runs; lower is better. The comparison retains every workload row, including parity and slower cases. A reproducible version of this benchmark lives in [`benchmarks/competitor_bench.py`](benchmarks/competitor_bench.py). +The complete comparison also ran `finditer_bench.py`, `sub_bench.py`, +`split_bench.py`, `free_threaded_bench.py`, `api_hotpaths.py`, +`parallel_map_hotpath.py`, and the opt-in `tests/test_benchmark.py` matrix for +both revisions; all 96 invocations completed successfully. Raw logs are in +`/tmp/pypcre-bench-20260822/logs/runs2` on the benchmark host. #### `findall` — large multiline and lookaround workloads | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge | | --- | ---: | ---: | ---: | --- | -| Extract `WARN` / `ERROR` lines (multiline) | `0.664` | `27.159` | `30.772` | **40.9x** vs `re`, **46.3x** vs `regex` | -| Per-line full-name extraction (multiline) | `0.914` | `25.685` | `14.482` | **28.1x** vs `re`, **15.8x** vs `regex` | -| Lookbehind + negative-lookahead tokens | `1.874` | `12.353` | `10.386` | **6.6x** vs `re`, **5.5x** vs `regex` | +| Extract `WARN` / `ERROR` lines (multiline) | `0.471` | `15.098` | `16.281` | **32.1x** vs `re`, **34.6x** vs `regex` | +| Per-line full-name extraction (multiline) | `0.588` | `14.305` | `8.000` | **24.3x** vs `re`, **13.6x** vs `regex` | +| Lookbehind + negative-lookahead tokens | `1.013` | `6.129` | `5.267` | **6.1x** vs `re`, **5.2x** vs `regex` | Patterns used: @@ -134,51 +139,51 @@ Patterns used: #### `finditer` — same workloads -Measured on a Python 3.10 x86_64 Linux build with compiled-pattern reuse and JIT enabled. A reproducible version lives in [`benchmarks/finditer_bench.py`](benchmarks/finditer_bench.py). +Measured on Python 3.10.11 arm64 with compiled-pattern reuse and JIT enabled. A reproducible version lives in [`benchmarks/finditer_bench.py`](benchmarks/finditer_bench.py). | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge | | --- | ---: | ---: | ---: | --- | -| Extract `WARN` / `ERROR` lines | `0.663` | `30.747` | `31.862` | **46.4x** vs `re`, **48.0x** vs `regex` | -| Per-line full-name extraction | `0.919` | `29.127` | `14.103` | **31.7x** vs `re`, **15.3x** vs `regex` | -| Lookbehind + negative lookahead | `3.755` | `15.828` | `11.896` | **4.2x** vs `re`, **3.2x** vs `regex` | +| Extract `WARN` / `ERROR` lines | `0.449` | `17.145` | `16.175` | **38.2x** vs `re`, **36.0x** vs `regex` | +| Per-line full-name extraction | `0.564` | `16.258` | `7.874` | **28.8x** vs `re`, **14.0x** vs `regex` | +| Lookbehind + negative lookahead | `1.942` | `8.548` | `6.037` | **4.4x** vs `re`, **3.1x** vs `regex` | #### `sub` / `subn` — high-volume replacement workloads -Measured on a Python 3.10 x86_64 Linux build with compiled-pattern reuse and JIT enabled. Times are the best of several runs; lower is better. The benchmark replaces 100,000 space-separated tokens. +Measured on Python 3.10.11 arm64 with compiled-pattern reuse and JIT enabled. Values are medians of three outer runs; lower is better. The benchmark replaces 100,000 space-separated tokens. A reproducible version lives in [`benchmarks/sub_bench.py`](benchmarks/sub_bench.py). | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge | | --- | ---: | ---: | ---: | --- | -| Literal replacement (`\w+` → `[X]`) | `5.933` | `13.367` | `19.107` | **2.3x** vs `re`, **3.2x** vs `regex` | -| Single numeric backref (`(w)\d+` → `[\1]`) | `7.221` | `64.715` | `25.482` | **9.0x** vs `re`, **3.5x** vs `regex` | -| Two numeric backrefs (`(w)(\d+)` → `\2-\1`) | `17.600` | `76.222` | `32.048` | **4.3x** vs `re`, **1.8x** vs `regex` | -| Named backref (`(?P\w+)` → `<\g>`) | `14.684` | `71.053` | `30.222` | **4.8x** vs `re`, **2.1x** vs `regex` | +| Literal replacement (`\w+` → `[X]`) | `3.127` | `6.489` | `9.992` | **2.1x** vs `re`, **3.2x** vs `regex` | +| Single numeric backref (`(w)\d+` → `[\1]`) | `4.003` | `30.098` | `12.481` | **7.5x** vs `re`, **3.1x** vs `regex` | +| Two numeric backrefs (`(w)(\d+)` → `\2-\1`) | `9.940` | `35.052` | `16.201` | **3.5x** vs `re`, **1.6x** vs `regex` | +| Named backref (`(?P\w+)` → `<\g>`) | `9.765` | `32.434` | `15.216` | **3.3x** vs `re`, **1.6x** vs `regex` | #### `split` — high-volume delimiter workloads -Measured on a Python 3.10 x86_64 Linux build with compiled-pattern reuse and JIT enabled. Times are the best of several runs; lower is better. The benchmark splits 100,000 space-separated tokens. +Measured on Python 3.10.11 arm64 with compiled-pattern reuse and JIT enabled. Values are medians of three outer runs; lower is better. The benchmark splits 100,000 space-separated tokens. A reproducible version lives in [`benchmarks/split_bench.py`](benchmarks/split_bench.py). | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge | | --- | ---: | ---: | ---: | --- | -| Delimiter no group (`\s+`) | `7.315` | `17.394` | `19.690` | **2.4x** vs `re`, **2.7x** vs `regex` | -| Delimiter with group (`(\s+)`) | `12.360` | `21.099` | `25.145` | **1.7x** vs `re`, **2.0x** vs `regex` | -| Single char (` `) | `4.583` | `3.990` | `14.577` | parity vs `re`, **3.2x** vs `regex` | -| Single char with group (`( )`) | `8.913` | `11.091` | `18.595` | **1.2x** vs `re`, **2.1x** vs `regex` | -| Empty pattern (`''`) | `5.228` | `45.552` | `69.913` | **8.7x** vs `re`, **13.4x** vs `regex` | +| Delimiter no group (`\s+`) | `3.349` | `8.013` | `9.913` | **2.4x** vs `re`, **3.0x** vs `regex` | +| Delimiter with group (`(\s+)`) | `5.080` | `9.142` | `12.374` | **1.8x** vs `re`, **2.4x** vs `regex` | +| Single char (` `) | `1.484` | `1.666` | `6.963` | **1.1x** vs `re`, **4.7x** vs `regex` | +| Single char with group (`( )`) | `1.772` | `4.203` | `8.875` | **2.4x** vs `re`, **5.0x** vs `regex` | +| Empty pattern (`''`) | `35.736` | `18.386` | `35.801` | **0.5x** vs `re`, parity vs `regex` | ### Free-Threaded Benchmark Highlights 🧵 -Measured on the same `Python 3.14.6` free-threaded build with `8` threads fanning out over split copies of each workload. Times are the best of several runs; lower is better. +Measured on the same Apple arm64 `Python 3.14.0rc2` free-threaded build with `8` threads fanning out over split copies of each workload. Values are medians of three outer runs; lower is better. A reproducible version lives in [`benchmarks/free_threaded_bench.py`](benchmarks/free_threaded_bench.py). | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge | | --- | ---: | ---: | ---: | --- | -| Extract `WARN` / `ERROR` lines (`findall`) | `0.672` | `9.063` | `9.297` | **13.5x** vs `re`, **13.8x** vs `regex` | -| Per-line full-name extraction (`findall`) | `0.913` | `8.611` | `4.575` | **9.4x** vs `re`, **5.0x** vs `regex` | +| Extract `WARN` / `ERROR` lines (`findall`) | `0.401` | `2.430` | `2.626` | **6.1x** vs `re`, **6.6x** vs `regex` | +| Per-line full-name extraction (`findall`) | `0.462` | `2.351` | `1.477` | **5.1x** vs `re`, **3.2x** vs `regex` | PyPcre is the stronger all-around choice when you want more than the baseline: full `PCRE2` features, more expressive syntax, JIT, explicit free-threaded support, and a stable `re`-compatible API surface. It keeps Python ergonomics while giving you a substantially more capable engine. 🚀 From 20cbc70a9b2d0124160754aab5ac2186f338ab63 Mon Sep 17 00:00:00 2001 From: Qubitium Date: Sat, 22 Aug 2026 12:21:55 +0800 Subject: [PATCH 5/5] docs: restore fanout comparison table Preserve the full scheduler-policy fanout and call-local benchmark comparison for both interpreters. Co-authored-by: omnigent --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index 157b0d1..e0fd6ab 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,76 @@ The complete comparison also ran `finditer_bench.py`, `sub_bench.py`, both revisions; all 96 invocations completed successfully. Raw logs are in `/tmp/pypcre-bench-20260822/logs/runs2` on the benchmark host. +#### Fan-out and call-local speedups + +Both interpreter runs used the same scheduler policy. The host reports 12 +performance logical CPUs and 4 efficiency logical CPUs; macOS does not provide +an unprivileged hard per-process CPU mask, so these measurements record the +topology rather than claiming hard CPU affinity. + +| Workload | Python 3.10 | Python 3.14t/GIL=0 | +| --- | ---: | ---: | +| `parallel_map(search)`, 16 × 1 MiB subjects, 12 workers | **8.57x** | **7.85x** | +| `parallel_map(findall)`, 48 × 1 MiB subjects, 12 workers | **11.51x** | **11.25x** | +| No-op `escape("literal")` | **5.0x** | **3.6x** | +| No-op `escape(b"literal")` | **6.9x** | **6.5x** | +| Bound literal `sub(..., count=1)` | **4.7x** | **4.3x** | +| Bound numeric-reference `sub(..., count=1)` | **4.3x** | **4.1x** | +| Bound explicit-reference `sub(..., count=1)` | **4.6x** | **4.3x** | +| Bound named-reference `sub(..., count=1)` | **4.2x** | **4.3x** | +| Bound numeric-reference `sub(..., count=2)` | **4.6x** | **4.2x** | +| Bound numeric-reference `sub(..., count=4)` | **6.0x** | **5.1x** | +| Bound numeric-reference `sub(..., count=8)` | **8.6x** | **6.1x** | +| Cached compile with `re.I` | **6.7x** | **6.9x** | +| Cached compile with `re.I|re.M|re.S|re.X` | **6.2x** | **6.4x** | +| First-read `lastindex` cost, sole capture | **11.8x** | **8.2x** | +| Deprecated `template()` compatibility call | **6.2x** | **1.1x** | +| Literal-capture `findall`, 100 matches | **5.0x** | **6.2x** | +| Literal-capture `findall`, 500 matches | **7.0x** | **8.4x** | +| Two literal captures `findall`, 100 matches | **7.5x** | **10.8x** | +| Two literal captures `findall`, 500 matches | **13.5x** | **18.3x** | +| Eight literal captures `findall`, 500 matches | **24.5x** | **29.4x** | +| Three literal captures `split`, 500 captures | **4.5x** | **6.5x** | +| Eight literal captures `split`, 500 captures | **6.6x** | **9.0x** | +| Literal-capture `split`, 100 captures | **2.6x** | **3.5x** | +| Literal-capture `split`, 2,000 captures | **3.1x** | **3.8x** | +| Bound one-character literal `Pattern.split` | **2.1x** | **1.7x** | +| Bound backreference `sub` hot path | **1.38 μs** | **1.14 μs** | +| One-match numeric-reference `Pattern.sub` | **0.45 μs** | **0.34 μs** | +| One-match explicit-reference `Pattern.sub` | **0.45 μs** | **0.31 μs** | +| One-match named-reference `Pattern.sub` | **0.46 μs** | **0.33 μs** | +| Repeated call-local `Match.groups()` | **~0.05 μs** | **~0.05 μs** | +| Call-local `Match.expand(r"[\\1]")` | **0.07 μs** | **0.07 μs** | +| Call-local `Match.expand(r"[\\g<1>]")` | **0.11 μs** | **0.08 μs** | +| Call-local `Match.expand(r"[\\g]")` | **0.13 μs** | **0.11 μs** | +| Call-local two-name `Match.expand` | **0.18 μs** | **0.15 μs** | +| Call-local three-name `Match.expand` | **0.23 μs** | **0.20 μs** | +| Call-local eight-name `Match.expand` | **0.44 μs** | **0.39 μs** | +| Literal-backslash + named `Match.expand` | **0.12 μs** | **0.10 μs** | +| Named + backslash-suffix `Match.expand` | **0.16 μs** | **0.13 μs** | +| Repeated default `compile("(x)")` | **0.49 μs** | **0.38 μs** | +| Repeated integer-flagged `compile("x", CASELESS)` | **1.16 μs** | **0.81 μs** | + +The parallel figures are serial-to-parallel speedups and preserve input order +and exception behavior. Large `findall` scans release the GIL only around the +PCRE2 call; match data, context, and subject ownership remain worker-local. +Reproduce the fan-out benchmark with the same scheduler policy for both +interpreters: + +```bash +taskpolicy -t 1 -l 1 env \ + PYPCRE_PARALLEL_WORKERS=12 \ + PYPCRE_PARALLEL_RUNS=9 \ + PYTHONPATH=. \ + ./.venv310/bin/python benchmarks/parallel_map_hotpath.py + +taskpolicy -t 1 -l 1 env \ + PYPCRE_PARALLEL_WORKERS=12 \ + PYPCRE_PARALLEL_RUNS=9 \ + PYTHONPATH=. \ + ./.venv-gil0/bin/python benchmarks/parallel_map_hotpath.py +``` + #### `findall` — large multiline and lookaround workloads | Workload | PyPcre (ms) | `re` (ms) | `regex` (ms) | PyPcre edge |