Skip to content

gh-158252: Add C implementation for strptime - #158253

Draft
pganssle wants to merge 7 commits into
python:mainfrom
pganssle:c-strptime-optimization
Draft

pganssle wants to merge 7 commits into
python:mainfrom
pganssle:c-strptime-optimization

Conversation

@pganssle

@pganssle pganssle commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Closes #158252.

This adds a shared C parser for common numeric formats used by datetime.datetime.strptime, datetime.date.strptime, datetime.time.strptime, and time.strptime. Each caller constructs its result directly from the parsed fields, avoiding the Python parser's regex cache, locale checks, and intermediate objects. It builds on @StanFromIreland's initial implementation.

The fast path handles ASCII numeric fields, including fractional seconds and a trailing %z with minute-resolution offsets or Z. Other inputs use the existing Python parser, preserving backtracking, warnings, and error messages. Subclasses also use the Python path so their constructor arguments don't change. Each caller retains its existing handling of discarded fields, leap seconds, and timezone information. The pure-Python implementations are unchanged.

Benchmarks compare the PR base, de0d9763682, with 207ae592412, using matching GCC 16.2.1 release builds (-O3, without PGO/LTO) and LC_TIME=C. All four methods parse the same 32 inputs for each format. These are warmed mean times per public call, including method lookup and loop overhead. The methods construct different results, so their timings aren't identical.

For datetime.datetime.strptime:

Format Before After Speedup
%Y-%m-%d 2.04 µs 82.2 ns 24.89×
%Y-%m-%d %H:%M:%S 2.68 µs 96.1 ns 27.86×
%Y-%m-%d %H:%M:%S.%f 2.86 µs 92.3 ns 30.97×
%Y%m%d%H%M%S 3.31 µs 84.2 ns 39.32×
%Y-%m-%dT%H:%M:%S%z (UTC) 3.45 µs 92.5 ns 37.32×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 3.86 µs 123.9 ns 31.16×
%H:%M:%S 2.31 µs 72.7 ns 31.79×
%H:%M:%S.%f%z (+0530) 3.87 µs 105.7 ns 36.66×
%Y-%m-%d %H:%M:%S + rotating suffix 6.98 µs 87.2 ns 80.11×
%d %B %Y %H:%M:%S ‡ 2.72 µs 2.72 µs —
%Y-%m-%d %I:%M:%S %p ‡ 3.18 µs 3.05 µs —
%d %B %Y %H:%M:%S + rotating suffix ‡ 7.00 µs 6.77 µs —

The rotating cases append literal suffixes /0 through /31 to the input and format, exceeding the Python parser's five-entry regex cache. ‡ These formats use the Python fallback. A dash (—) in the speedup column means pyperf didn't find a statistically significant difference; it doesn't identify the parsing path.

datetime.date.strptime benchmarks
Format Before After Speedup
%Y-%m-%d 2.37 µs 73.8 ns 32.17×
%Y-%m-%d %H:%M:%S 2.55 µs 94.1 ns 27.08×
%Y-%m-%d %H:%M:%S.%f 2.72 µs 91.7 ns 29.70×
%Y%m%d%H%M%S 2.49 µs 101.7 ns 24.43×
%Y-%m-%dT%H:%M:%S%z (UTC) 3.03 µs 83.5 ns 36.22×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 3.35 µs 98.3 ns 34.03×
%H:%M:%S 2.19 µs 68.3 ns 32.07×
%H:%M:%S.%f%z (+0530) 2.98 µs 88.0 ns 33.88×
%Y-%m-%d %H:%M:%S + rotating suffix 6.44 µs 110.0 ns 58.57×
%d %B %Y %H:%M:%S ‡ 2.68 µs 2.71 µs —
%Y-%m-%d %I:%M:%S %p ‡ 2.80 µs 2.79 µs —
%d %B %Y %H:%M:%S + rotating suffix ‡ 6.29 µs 6.37 µs 0.99×
datetime.time.strptime benchmarks
Format Before After Speedup
%Y-%m-%d 2.10 µs 70.4 ns 29.77×
%Y-%m-%d %H:%M:%S 2.67 µs 81.4 ns 32.75×
%Y-%m-%d %H:%M:%S.%f 2.90 µs 84.4 ns 34.41×
%Y%m%d%H%M%S 2.70 µs 79.2 ns 34.07×
%Y-%m-%dT%H:%M:%S%z (UTC) 3.42 µs 83.8 ns 40.82×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 4.10 µs 112.6 ns 36.42×
%H:%M:%S 2.23 µs 81.0 ns 27.47×
%H:%M:%S.%f%z (+0530) 3.46 µs 101.3 ns 34.18×
%Y-%m-%d %H:%M:%S + rotating suffix 6.21 µs 102.7 ns 60.52×
%d %B %Y %H:%M:%S ‡ 2.64 µs 2.68 µs —
%Y-%m-%d %I:%M:%S %p ‡ 2.89 µs 2.86 µs —
%d %B %Y %H:%M:%S + rotating suffix ‡ 6.60 µs 6.41 µs 1.03×
time.strptime benchmarks
Format Before After Speedup
%Y-%m-%d 2.12 µs 101.8 ns 20.83×
%Y-%m-%d %H:%M:%S 2.76 µs 129.2 ns 21.35×
%Y-%m-%d %H:%M:%S.%f 3.00 µs 117.8 ns 25.42×
%Y%m%d%H%M%S 2.82 µs 111.1 ns 25.39×
%Y-%m-%dT%H:%M:%S%z (UTC) 3.20 µs 109.0 ns 29.33×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 3.52 µs 127.2 ns 27.64×
%H:%M:%S 2.41 µs 97.9 ns 24.58×
%H:%M:%S.%f%z (+0530) 3.14 µs 110.9 ns 28.33×
%Y-%m-%d %H:%M:%S + rotating suffix 6.53 µs 116.7 ns 55.95×
%d %B %Y %H:%M:%S ‡ 2.88 µs 2.81 µs 1.03×
%Y-%m-%d %I:%M:%S %p ‡ 2.95 µs 3.07 µs 0.96×
%d %B %Y %H:%M:%S + rotating suffix ‡ 6.64 µs 6.64 µs —

The initial runs were noisy, so all fallback and UTC rows use longer, closely paired repeats. Eight of the twelve fallback comparisons weren't significant. The repeats measured time.strptime with AM/PM about 4% slower and date.strptime with rotating month-name formats about 1.3% slower; two other fallback comparisons were about 3% faster. Small fallback differences remain sensitive to measurement variability.

The three targeted test modules (test_datetime, test_strptime, and test_time) pass. Differential checks found no differences in 74,708 public API comparisons, 18,677 additional metadata/error/warning comparisons, and 119,988 calendar checks against the pure-Python implementation.

Benchmark method and reproducer

Workers were pinned to one CPU, with builds and correctness tests completed before timing. The full suite ran in base/PR/PR/base order, with two worker processes per run, four values per worker, two warmups, and a 50 ms minimum sample time. UTC and fallback cases were repeated individually in the same order, with six values, three warmups, and a 100 ms minimum. The tables use those repeats for UTC and fallback rows and the full-suite runs for the remaining rows. All 1,536 expected results, including struct_time timezone metadata, are checked before timing.

Build de0d9763682 and 207ae592412 in separate checkouts with GCC 16.2.1 and the same options:

./configure CFLAGS='-O3 -g -fno-omit-frame-pointer' --without-ensurepip
make -j4

Save the script below as bench_strptime.py. Set the interpreter paths and choose an available CPU for BENCH_CPU. The first group measures all 48 combinations; the remaining groups repeat UTC and fallback cases with closer pairing and longer samples.

set -euo pipefail
BASE_PYTHON=/path/to/base/python
PR_PYTHON=/path/to/pr/python
BENCH_CPU=2
BENCH_DEPS=$(mktemp -d)
BENCH_RESULTS=$(mktemp -d)
uv pip install --python "$PR_PYTHON" --target "$BENCH_DEPS" pyperf==2.9.0
export PYTHONPATH="$BENCH_DEPS"

measure() {
    bench_label=$1
    shift
    for sample in 1-base 2-pr 3-pr 4-base; do
        case "$sample" in
            *-base) bench_python=$BASE_PYTHON ;;
            *-pr) bench_python=$PR_PYTHON ;;
        esac
        "$bench_python" bench_strptime.py "$@" --affinity "$BENCH_CPU" \
            -o "$BENCH_RESULTS/$bench_label-$sample.json"
    done
    "$PR_PYTHON" -m pyperf convert "$BENCH_RESULTS/$bench_label-1-base.json" \
        --add "$BENCH_RESULTS/$bench_label-4-base.json" \
        -o "$BENCH_RESULTS/$bench_label-base.json"
    "$PR_PYTHON" -m pyperf convert "$BENCH_RESULTS/$bench_label-2-pr.json" \
        --add "$BENCH_RESULTS/$bench_label-3-pr.json" \
        -o "$BENCH_RESULTS/$bench_label-pr.json"
    "$PR_PYTHON" -m pyperf compare_to "$BENCH_RESULTS/$bench_label-base.json" \
        "$BENCH_RESULTS/$bench_label-pr.json" --table
}

measure all --processes 2 --values 4 --warmups 2 --min-time 0.05
for method in datetime date datetime_time time; do
    for case_name in month_name ampm rotating_fallback utc; do
        measure "$method-$case_name" --method "$method" --case "$case_name" \
            --processes 2 --values 6 --warmups 3 --min-time 0.1
    done
done
# /// script
# requires-python = ">=3.13"
# dependencies = ["pyperf==2.9.0"]
# ///
"""Identical strptime inputs and formats across all four public entry points."""

import argparse
import dataclasses
import datetime
import locale
import sys
import time
from collections.abc import Callable

import pyperf


@dataclasses.dataclass(frozen=True)
class Case:
    name: str
    inputs: tuple[tuple[str, str, datetime.datetime], ...]


@dataclasses.dataclass(frozen=True)
class Method:
    name: str
    expression: str
    parse: Callable[[str, str], object]
    expected: Callable[[datetime.datetime], object]


def methods() -> tuple[Method, ...]:
    return (
        Method("datetime", "datetime.datetime", datetime.datetime.strptime,
               lambda value: value),
        Method("date", "datetime.date", datetime.date.strptime,
               lambda value: value.date()),
        Method("datetime_time", "datetime.time", datetime.time.strptime,
               lambda value: value.timetz()),
        Method("time", "time", time.strptime, lambda value: value.timetuple()),
    )


def cases() -> tuple[Case, ...]:
    formats = (
        ("numeric_date", "%Y-%m-%d"),
        ("timestamp", "%Y-%m-%d %H:%M:%S"),
        ("fraction", "%Y-%m-%d %H:%M:%S.%f"),
        ("compact", "%Y%m%d%H%M%S"),
        ("utc", "%Y-%m-%dT%H:%M:%S%z"),
        ("offset", "%Y-%m-%dT%H:%M:%S.%f%z"),
        ("time_only", "%H:%M:%S"),
        ("time_offset", "%H:%M:%S.%f%z"),
        ("month_name", "%d %B %Y %H:%M:%S"),
        ("ampm", "%Y-%m-%d %I:%M:%S %p"),
    )
    result = []
    for name, fmt in formats:
        inputs = []
        for i in range(32):
            value = datetime.datetime(2000 + i % 25, 1 + i % 12,
                                      1 + i % 28, i % 24, i * 7 % 60,
                                      i * 11 % 60)
            if name in {"time_only", "time_offset"}:
                value = value.replace(year=1900, month=1, day=1)
            if "%f" in fmt:
                value = value.replace(microsecond=i * 31249)
            if name == "numeric_date":
                value = value.replace(hour=0, minute=0, second=0)
            if "%z" in fmt:
                offset = datetime.timedelta(minutes=0 if name == "utc" else 330)
                value = value.replace(tzinfo=datetime.timezone(offset))
            inputs.append((value.strftime(fmt), fmt, value))
        result.append(Case(name, tuple(inputs)))
    for name, source in (("rotating_numeric", "timestamp"),
                         ("rotating_fallback", "month_name")):
        case = next(case for case in result if case.name == source)
        inputs = tuple((text + f"/{i}", fmt + f"/{i}", expected)
                       for i, (text, fmt, expected) in enumerate(case.inputs))
        result.append(Case(name, inputs))
    return tuple(result)


def check(method: Method, case: Case) -> None:
    for text, fmt, value in case.inputs:
        actual = method.parse(text, fmt)
        expected = method.expected(value)
        assert type(actual) is type(expected)
        assert actual == expected, (method.name, case.name, text, actual, expected)
        if isinstance(actual, time.struct_time):
            offset = value.utcoffset()
            assert actual.tm_zone is None
            assert actual.tm_gmtoff == (None if offset is None else offset.total_seconds())


def worker_args(command: list[str], args: argparse.Namespace) -> None:
    for option in ("method", "case"):
        if value := getattr(args, option):
            command.extend((f"--{option}", value))


def main() -> None:
    locale.setlocale(locale.LC_TIME, "C")
    suite = cases()
    entrypoints = methods()
    for method in entrypoints:
        for case in suite:
            check(method, case)
    if "--smoke" in sys.argv:
        print(f"Checked {len(entrypoints) * sum(len(case.inputs) for case in suite)} results")
        return
    runner = pyperf.Runner(add_cmdline_args=worker_args)
    runner.argparser.add_argument("--method", choices=tuple(method.name for method in entrypoints))
    runner.argparser.add_argument("--case", choices=tuple(case.name for case in suite))
    args = runner.parse_args()
    runner.metadata["description"] = "Same 32 inputs/formats for all four public strptime methods"
    runner.metadata["LC_TIME"] = "C"
    for method in entrypoints:
        if args.method and args.method != method.name:
            continue
        for case in suite:
            if args.case and args.case != case.name:
                continue
            runner.timeit(
                f"{method.name}.{case.name}",
                f"for text, fmt, _ in inputs:\n    {method.expression}.strptime(text, fmt)",
                setup="import datetime; import time; import _strptime",
                globals={"inputs": case.inputs},
                inner_loops=len(case.inputs),
            )


if __name__ == "__main__":
    main()

StanFromIreland and others added 3 commits September 20, 2026 17:40
Restrict the fast path to independent ASCII numeric fields and defer
unusual formats and mismatches to the Python parser. This preserves
regex backtracking, duplicate-directive errors, and current diagnostics
for dates without a year.

Use string lengths to preserve embedded NULs, validate calendar dates
before returning time tuples, and preserve allocation failures.
The compatible accelerator spends more time building and unpacking Python
objects and traversing wrappers than parsing fields. Keep those fields in
C and use the existing datetime and timezone construction machinery.

Move the numeric parser into _datetime and remove the separate extension.
Exact datetime instances can avoid importing _strptime and acquiring its
locale/cache lock. Subclasses, pure-Python datetime, and other entry points
retain the Python path, including constructor arguments and diagnostics.

Drop unused directive and date-normalization code. Parsing itself neither
allocates nor sets exceptions; failure requests Python fallback. Errors from
result construction propagate normally.
Move numeric field parsing into pytime so the time and datetime modules can use it without importing each other. Keep result construction in the caller and expose the day of year for struct_time construction.
Construct date, time, and struct_time results from the shared numeric fields. Preserve subclass constructors and Python fallback behavior, including leap seconds, discarded fields, and timezone metadata.

@pganssle pganssle left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Few comments for the agent.

Comment thread Modules/timemodule.c Outdated
}
/* January 1 of year 1 was a Monday. */
int year = fields.year - 1;
int weekday = (365 * year + year / 4 - year / 100 + year / 400 +

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This should be its own function somewhere. We must already have this, right?

Comment thread Python/pytime.c Outdated

/* Locale-independent numeric strptime parsing.
*
* Return 1 for a complete numeric match, or 0 to use Lib/_strptime.py.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Best to explain a bit more what this is, like so:

This implements the most common parts of the strptime spec
in C to improve performance; if this fails callers should fall back
to _strptime for a full parse.

Returns 1 for a complete numeric match or 0 if fallback is required.

Parsing doesn't allocate or set exceptions; in particular, mismatches
may require regex backtracking, so the fallback should diagnose them.

I am also unclear: are there situations where the fallback can be skipped? For example:

datetime.strptime("2021-13-04", "%Y-%m-%d")

This parses correctly but validates incorrectly. Parsing it again won't change that result.

I also imagine that for known formats you can also see that it parses correctly and know that the fallback won't fix it, like, ("", "%Y-%m-%d"), and I don't see why regex backtracking would be required. Maybe there's some other reason why the fallback should always be used to set the error conditions, but if so it should be clearer from the comment here.

Comment thread Python/pytime.c
Reuse the datetime calendar arithmetic for struct_time construction. Document the numeric parser scope and why even known-invalid inputs use the Python parser for error messages and error precedence.
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.

C implementation for strptime

2 participants