Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
80fc473
perf(pcre): accelerate literal substitution hot paths
Qubitium Aug 9, 2026
e3cd40c
perf: batch parallel maps and vectorize iteration paths
Qubitium Aug 9, 2026
da8d385
perf: scale safe lookups across performance cores
Qubitium Aug 9, 2026
f415b1f
perf: accelerate scans and replacement hot paths
Qubitium Aug 9, 2026
e67bb36
docs: record API speedup benchmarks
Qubitium Aug 9, 2026
e0c96d0
docs: order latest news by date
Qubitium Aug 9, 2026
36679c6
perf: cache immutable match groups
Qubitium Aug 9, 2026
23d1d33
perf: accelerate canonical module helpers
Qubitium Aug 9, 2026
ea34cda
perf: cache repeated expand template parsing
Qubitium Aug 9, 2026
2eab05e
perf: accelerate common expand render shapes
Qubitium Aug 9, 2026
c7da398
perf: accelerate repeated default compile
Qubitium Aug 9, 2026
3633fb1
perf: cache integer-flagged builtin compiles
Qubitium Aug 9, 2026
516a4fd
perf: cache exact Flag enum compile calls
Qubitium Aug 10, 2026
c172324
perf: bypass executor for single-item parallel maps
Qubitium Aug 10, 2026
58d0fa7
perf: inline tiny explicit parallel maps
Qubitium Aug 10, 2026
9eb4725
test: cover tiny parallel map fallbacks
Qubitium Aug 10, 2026
58aa683
docs: record tiny parallel map speedup
Qubitium Aug 10, 2026
770498a
perf: inline up to eight tiny parallel subjects
Qubitium Aug 10, 2026
858d4c2
test: cover eight-subject parallel fallback
Qubitium Aug 10, 2026
28a482a
perf: fast-path default Pattern.split
Qubitium Aug 10, 2026
f275b93
test: cover split compatibility fallbacks
Qubitium Aug 10, 2026
90136bf
docs: record bound split speedup
Qubitium Aug 10, 2026
e95337a
perf: fast-path bound literal substitutions
Qubitium Aug 10, 2026
ed8da84
fix: keep enum compile calls on hardened cache
Qubitium Aug 10, 2026
ad7cb44
docs: record literal substitution speedup
Qubitium Aug 10, 2026
468fc9e
perf: fast-path literal one-character splits
Qubitium Aug 10, 2026
a603edf
perf: extend literal split fast path
Qubitium Aug 10, 2026
7f4587e
docs: record literal split extension
Qubitium Aug 10, 2026
d21dd56
perf: fast-path literal substitutions
Qubitium Aug 10, 2026
bb1e87c
perf: reuse literal dispatch in module helpers
Qubitium Aug 10, 2026
8228409
docs: record literal substitution speedup
Qubitium Aug 10, 2026
92b3dca
perf: fast-path literal findall
Qubitium Aug 10, 2026
daa9c7c
docs: record literal findall speedup
Qubitium Aug 10, 2026
a12e77c
fix: preserve negative split and substitution counts
Qubitium Aug 10, 2026
e1e8190
test: close literal fast-path coverage
Qubitium Aug 10, 2026
863d1ce
docs: clarify literal substitution count semantics
Qubitium Aug 10, 2026
ab3dbcf
perf: route bounded literal module substitutions
Qubitium Aug 10, 2026
c746f23
fix cache scope and retention safety
Qubitium Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Fast, free-threaded Python bindings for `PCRE2` with a stable `stdlib.re`-compat


## Latest News 🚀
* 08/10/2026 **Literal split/substitution/findall fast paths**: exact plain-literal `Pattern.split` calls now use the immutable built-in splitter after construction-time validation, measuring **2.1x** faster than the prior C dispatch on Python 3.10 and **1.7x** faster on free-threaded Python 3.14t/GIL=0; delimiter-heavy multi-character literals reach roughly **4.8x**. Literal `Pattern.subn` and module-level `sub`/`subn` now use native replace/count primitives, reaching about **15x** on short repeated tokens and **3x** on delimiter-heavy text. Literal `findall` uses non-overlapping native count/list construction, reaching about **9x** on short repeated tokens and **8x** on delimiter-heavy text. Regex metacharacters, explicit flags, subclasses, and buffer subjects remain on the compatibility-safe PCRE2 path. ⚡
* 08/09/2026 **API hot-path update**: large `parallel_map(findall)` workloads now reach **11.5x** speedup on Python 3.10 and **11.25x** on free-threaded Python 3.14t/GIL=0 with 12 performance-tier workers. Ordered `parallel_map(search)` reaches **8.57x** and **7.85x**, respectively; one-item and up to eight tiny explicit `parallel_map` subjects now avoid executor setup (the one-item case measures **13.3x** faster on Python 3.10 and **27.7x** on 3.14t), default bound `Pattern.split` is another **1.6x/1.5x** faster on 3.10/3.14t, and default bound literal `Pattern.subn` is about **1.5x** faster on Python 3.10. Canonical module helpers retain their optimized dispatch while their wrapper/template caches are thread-scoped, size-bounded, and invalidated across live workers. Repeated backreference `Match.expand()` avoids reparsing within the active cache context, while captured values returned by `Match.groups()` remain call-local so a long-lived Match does not retain an additional copy of large captures. 🧵⚡
* 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. 🛡️⚡
Expand Down Expand Up @@ -65,6 +67,39 @@ 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** |
| Bound one-character literal `Pattern.split` | **2.1x** | **1.7x** |
| Bound backreference `sub` hot path | **1.38 μs** | **1.14 μs** |
| Repeated call-local `Match.groups()` | **~0.05 μs** | **~0.05 μs** |
| Repeated `Match.expand(r"[\\1]")` | **5.23 μs** | **1.10 μ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:

```bash
taskpolicy -t 1 -l 1 env PYTHONPATH=. \
PYPCRE_PARALLEL_WORKERS=12 PYPCRE_PARALLEL_RUNS=3 \
python3 benchmarks/parallel_map_hotpath.py
```

The same script runs under Python 3.14t. The API microbenchmarks are available in
[`benchmarks/api_hotpaths.py`](benchmarks/api_hotpaths.py).

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.

A reproducible version of this benchmark lives in [`benchmarks/competitor_bench.py`](benchmarks/competitor_bench.py).
Expand Down Expand Up @@ -280,7 +315,13 @@ the conversion without repeating the flag.
- `pcre.configure(jit=False)` disables JIT globally. `Flag.JIT` and
`Flag.NO_JIT` let you override that per pattern.
- `pcre.set_cache_limit()`, `pcre.get_cache_limit()`, and `pcre.clear_cache()`
control the high-level compile cache.
control every high-level compile/template helper cache in the active context.
A zero limit disables them, and `None` uses a 256-entry hard safety ceiling
rather than permitting unbounded growth. High-level cache entries never cross
thread scope in the default thread-local strategy; a clear invalidates live
workers' high-level helper caches on their next cache-backed call. Backend
scratch buffers remain thread-scoped and are released when that thread exits.
Oversized patterns and templates are not retained.
- `pcre.configure_threads()`, `pcre.configure_thread_pool()`,
`shutdown_thread_pool()`, `Flag.THREADS`, and `Flag.NO_THREADS` are available
if you want to opt into or restrict threaded execution.
Expand Down
86 changes: 86 additions & 0 deletions benchmarks/api_hotpaths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Reproducible API hot-path benchmark for CPython 3.10 and 3.14t.

Run from the repository root with either interpreter, for example::

PYTHONPATH=. python3 benchmarks/api_hotpaths.py
PYTHONPATH=. python3.14t benchmarks/api_hotpaths.py

The benchmark intentionally uses short subjects so Python dispatch, template
parsing, and object-wrapper costs are visible instead of being hidden by a
large PCRE2 scan. Set ``PYPCRE_BENCH_RUNS`` to change the iteration count.
When running on a free-threaded build, an additional shared-pattern workload
checks the concurrent execution path.
"""

from __future__ import annotations

import concurrent.futures
import os
import sys
import time
from collections.abc import Callable

import pcre


RUNS = int(os.getenv("PYPCRE_BENCH_RUNS", "50000"))


def _time(fn: Callable[[], object]) -> float:
started = time.perf_counter()
for _ in range(RUNS):
fn()
return (time.perf_counter() - started) * 1_000_000.0 / RUNS


def main() -> int:
subject = "x" * 1000
short_subject = "x" * 10
pattern = pcre.compile("(x)")
captured = pattern.match(short_subject)
if captured is None:
raise AssertionError("benchmark pattern failed to produce a match")
operations: list[tuple[str, Callable[[], object]]] = [
("bound.match", lambda: pattern.match(subject)),
("bound.search", lambda: pattern.search(subject)),
("bound.fullmatch", lambda: pattern.fullmatch(subject)),
("bound.findall", lambda: pattern.findall(short_subject)),
("bound.finditer", lambda: list(pattern.finditer(short_subject))),
("bound.split", lambda: pattern.split("x " * 8)),
("bound.sub.literal", lambda: pattern.sub("[X]", short_subject)),
("bound.sub.backref", lambda: pattern.sub(r"[\1]", short_subject)),
("match.groups", captured.groups),
("module.match", lambda: pcre.match("(x)", subject)),
("module.search", lambda: pcre.search("(x)", subject)),
("module.fullmatch", lambda: pcre.fullmatch("(x)", subject)),
("module.findall", lambda: pcre.findall("(x)", short_subject)),
("module.finditer", lambda: list(pcre.finditer("(x)", short_subject))),
("module.split", lambda: pcre.split("(x)", "x " * 8)),
("module.sub.literal", lambda: pcre.sub("(x)", "[X]", short_subject)),
]

gil_enabled = getattr(sys, "_is_gil_enabled", lambda: True)()
print(f"runtime={sys.version.split()[0]} gil_enabled={gil_enabled} runs={RUNS}")
for name, operation in operations:
print(f"{name:22s} {_time(operation):8.3f} us")

if not gil_enabled:
workers = 8
per_worker = max(1, RUNS // 5)

def shared_search(_: int) -> int:
for _ in range(per_worker):
pattern.search(subject)
return per_worker

started = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
completed = sum(pool.map(shared_search, range(workers)))
elapsed_ms = (time.perf_counter() - started) * 1000.0
print(f"shared.search.{workers}T {elapsed_ms:8.3f} ms ({completed} calls)")

return 0


if __name__ == "__main__":
raise SystemExit(main())
91 changes: 91 additions & 0 deletions benchmarks/parallel_map_hotpath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Measure the batched :func:`pcre.parallel_map` workload.

Run from the repository root with either interpreter::

PYTHONPATH=. python3 benchmarks/parallel_map_hotpath.py
PYTHONPATH=. python3.14t benchmarks/parallel_map_hotpath.py

On macOS, use the same scheduler policy for both A/B runs and use the twelve
performance-tier logical workers exposed by this host, for example::

taskpolicy -t 1 -l 1 env PYTHONPATH=. PYPCRE_PARALLEL_WORKERS=12 python3 benchmarks/parallel_map_hotpath.py

macOS exposes the performance/efficiency cluster counts but not a portable
per-process CPU mask; the benchmark prints both counts so a run cannot be
mistaken for a hard CPU pin.

The subjects are intentionally large enough for PCRE2 to amortize worker
startup and queueing. This reports the serial baseline, threaded execution,
and the speedup while preserving result order.
"""

from __future__ import annotations

import os
import subprocess
import sys
import time
from collections.abc import Callable

import pcre


SUBJECT_COUNT = int(os.getenv("PYPCRE_PARALLEL_SUBJECTS", "16"))
SUBJECT_SIZE = int(os.getenv("PYPCRE_PARALLEL_SIZE", "1000000"))
RUNS = int(os.getenv("PYPCRE_PARALLEL_RUNS", "5"))
WORKERS = int(os.getenv("PYPCRE_PARALLEL_WORKERS", "12"))


def _topology() -> str:
if sys.platform != "darwin":
return "topology=non-darwin"
values: list[str] = []
for name in ("hw.perflevel0.logicalcpu", "hw.perflevel1.logicalcpu"):
try:
value = subprocess.check_output(
["sysctl", "-n", name], text=True, stderr=subprocess.DEVNULL
).strip()
except (OSError, subprocess.CalledProcessError):
value = "unknown"
values.append(value)
return f"performance_logical={values[0]} efficiency_logical={values[1]}"


def _best_ms(fn: Callable[[], object]) -> float:
callable_fn = fn # Keep the timed call outside the loop's attribute lookup.
best = float("inf")
for _ in range(RUNS):
started = time.perf_counter()
callable_fn()
best = min(best, (time.perf_counter() - started) * 1000.0)
return best


def main() -> int:
pattern = pcre.compile(r"\d+", pcre.Flag.THREADS)
subjects = ["x" * SUBJECT_SIZE + "123"] * SUBJECT_COUNT
serial = lambda: [pattern.search(subject) for subject in subjects]
parallel = lambda: pattern.parallel_map(
subjects, method="search", max_workers=WORKERS
)

serial_result = serial()
parallel_result = parallel()
if [bool(item) for item in serial_result] != [
bool(item) for item in parallel_result
]:
raise AssertionError("parallel_map changed result order or match presence")

serial_ms = _best_ms(serial)
parallel_ms = _best_ms(parallel)
print(
f"{_topology()} subjects={SUBJECT_COUNT} size={SUBJECT_SIZE} "
f"workers={WORKERS} runs={RUNS} "
f"serial={serial_ms:.3f}ms parallel={parallel_ms:.3f}ms "
f"speedup={serial_ms / parallel_ms:.2f}x"
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading