Skip to content

Speed up regex validation with vectorcall - #1295

Open
Siyet wants to merge 3 commits into
mainfrom
speed-up-re-pattern-search
Open

Siyet wants to merge 3 commits into
mainfrom
speed-up-re-pattern-search

Conversation

@Siyet

@Siyet Siyet commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Cache the search method name in the module state and use PyObject_CallMethodOneArg for string pattern validation.

Extend the pattern tests to cover Unicode \w matches and $ matching before a final newline.

Warm JSON decode/validate/encode benchmarks against main, with prebuilt decoders and encoders and no PCRE2 installed. Local Linux ARM64 Docker, CPython 3.14.7, pyperf 2.10.0, CPU 0 affinity, ABBA order, 30 measured values per variant and workload:

Workload main vectorcall Speedup
Page of 100 city records 148.58 us 139.68 us 1.06x
299 identifiers 54.21 us 40.10 us 1.35x
16 country codes 2.17 us 1.53 us 1.42x
Same 299 strings without pattern validation 7.09 us 7.10 us Not significant

An independent repeat with fresh builds gave 1.11x, 1.28x and 1.45x respectively, with no significant difference in the no-pattern control. The full-page result varied: one BA pair in the first run showed no significant difference. These are workload-specific local measurements, not an HTTP throughput estimate.

Benchmark workloads and commands

Save the script below as benchmark.py and download benchmark-data.zip into the same directory. The archive contains the same 100 city records used for the table; the script derives the same 299 identifiers from them. Decoder and encoder construction is outside the timed operation. The script reproduces the four workloads; measurements on newer source revisions may differ from the results above.

The city fixture is adapted from GeoNames, under CC BY 4.0.

import gc
import importlib.util
import json
import os
import sys
from pathlib import Path
from typing import Annotated, ClassVar, Generic, Literal, TypeVar

import msgspec
import pyperf
from msgspec import Meta


def valid_text(value):
    if isinstance(value, str):
        if '\x00' in value:
            raise ValueError('NUL is not permitted')
        try:
            value.encode('utf-8')
        except UnicodeEncodeError:
            raise ValueError('Unicode scalar values required') from None

Source = Literal['geonames', 'ourairports', 'local']

Identifier = Annotated[str, Meta(min_length=1, max_length=160, pattern=r'^[a-z]+:[a-z]+:[A-Za-z0-9_.-]+$')]

Name = Annotated[str, Meta(min_length=1, max_length=200)]

Population = Annotated[int, Meta(ge=0, le=9223372036854775807)]

Latitude = Annotated[float, Meta(ge=-90, le=90)]

Longitude = Annotated[float, Meta(ge=-180, le=180)]

class Record(msgspec.Struct, forbid_unknown_fields=True):
    kind: ClassVar[str]
    id: Identifier
    source: Source
    name: Name

    def __post_init__(self):
        for field in self.__struct_fields__:
            valid_text(getattr(self, field))
        if not self.id.startswith(f'{self.source}:{self.kind}:'):
            raise ValueError('id namespace does not match source and resource')

class City(Record):
    kind = 'city'
    source: Literal['geonames', 'local']
    country_id: Identifier
    division_id: Identifier | None
    latitude: Latitude
    longitude: Longitude
    population: Population
    feature_code: Literal['PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLA5', 'PPLC']

T = TypeVar('T', bound=Record)

class Page(msgspec.Struct, Generic[T], forbid_unknown_fields=True):
    items: list[T]
    next_after_id: str | None


ROWS = json.loads(
    Path(__file__).with_name("cities100.json").read_text(encoding="utf-8")
)


assert importlib.util.find_spec("pcre2") is None
probe = msgspec.Meta(pattern=r"^[A-Z]{2}$")
assert any(
    type(ref).__module__ == "re" and type(ref).__name__ == "Pattern"
    for ref in gc.get_referents(probe)
)
identifiers = [
    row[field]
    for row in ROWS
    for field in ("id", "country_id", "division_id")
    if row[field] is not None
]
assert len(ROWS) == 100 and len(identifiers) == 299
page = {"items": ROWS, "next_after_id": ROWS[-1]["id"]}
ID = Annotated[str, msgspec.Meta(pattern=r"^[a-z]+:[a-z]+:[A-Za-z0-9_.-]+$")]
CountryCode = Annotated[str, msgspec.Meta(pattern=r"^[A-Z]{2}$")]
encode = msgspec.json.Encoder().encode
cases = {
    "page100": (msgspec.json.Decoder(Page[City]), encode(page)),
    "identifier299": (msgspec.json.Decoder(list[ID]), encode(identifiers)),
    "country16": (
        msgspec.json.Decoder(list[CountryCode]),
        encode([
            "AU", "BR", "CA", "CN", "DE", "FR", "GB", "IN",
            "JP", "MX", "NZ", "RU", "SG", "US", "ZA", "AE",
        ]),
    ),
    "no_pattern299": (msgspec.json.Decoder(list[str]), encode(identifiers)),
}
operations = {}
for name, (decoder, raw) in cases.items():
    decoded = decoder.decode(raw)
    assert json.loads(encode(decoded)) == json.loads(raw)

    def operation(read=decoder.decode, raw=raw):
        return encode(read(raw))

    operations[name] = operation

# Check search semantics before timing; use escaped Unicode examples.
edge = msgspec.json.Decoder(Annotated[str, msgspec.Meta(pattern=r"^\w+$")])
for text in ["abc", "\u03b1\u03b2\u03b3", "\u00e9", "A_9", "abc\n"]:
    assert edge.decode(encode(text)) == text
for text in ["", "x\x00", "\U0001f600", "\ud800"]:
    try:
        edge.decode(encode(text))
    except (msgspec.ValidationError, msgspec.EncodeError, UnicodeError):
        pass
    else:
        raise AssertionError(text)

if __name__ == "__main__":
    if "--validate" in sys.argv:
        print({name: len(raw) for name, (_, raw) in cases.items()})
    else:
        runner = pyperf.Runner()
        runner.metadata.update(
            cell=os.environ.get("CELL", "unspecified"),
            scope="warm JSON decode/validate/encode; no PCRE2",
        )
        for name, operation in operations.items():
            runner.bench_func(name, operation)

Use CPython 3.14.7 and two separate environments with the main and PR builds of msgspec installed, without PCRE2. In a new working directory containing benchmark.py and benchmark-data.zip, set the environment paths below and run the ABBA sequence:

set -eu
# Use two environments containing the respective msgspec builds.
MAIN_PY=/path/to/main-env/bin/python
PR_PY=/path/to/pr-env/bin/python
"$MAIN_PY" -m pip install pyperf==2.10.0
"$PR_PY" -m pip install pyperf==2.10.0
"$MAIN_PY" -m zipfile -e benchmark-data.zip .
CELL=main "$MAIN_PY" benchmark.py --validate
CELL=pr "$PR_PY" benchmark.py --validate

run() {
    cell=$1
    python_bin=$2
    output=$3
    CELL=$cell "$python_bin" benchmark.py --affinity 0 --processes 3 --values 5 --warmups 2 --min-time 0.1 --inherit-environ CELL -o "$output"
}
run main "$MAIN_PY" main-a.json
run pr "$PR_PY" pr-a.json
run pr "$PR_PY" pr-b.json
run main "$MAIN_PY" main-b.json
"$MAIN_PY" -m pyperf convert main-a.json --add main-b.json -o main.json
"$MAIN_PY" -m pyperf convert pr-a.json --add pr-b.json -o pr.json
"$MAIN_PY" -m pyperf compare_to main.json pr.json --table

Comment thread src/msgspec/_core.c Outdated
if (type->types & MS_CONSTR_STR_REGEX) {
PyObject *regex = TypeNode_get_constr_str_regex(type);
PyObject *res = PyObject_CallMethod(regex, "search", "O", obj);
MsgspecState *st = msgspec_get_global_state();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you try reaching for the mod state directly? We're going to have to replace the global state anyway as part of #1024 (once we've moved to heap types)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. The decoders now cache the module state and pass it to string validation. convert uses its existing module state

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants