Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions benchmarks/abbr_scan_compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Head-to-head: our Aho-Corasick abbreviation scan vs a pySBD-style `in` loop.

Settles which abbreviation-*discovery* mechanism is faster at each input size.
Both find the identical set of present abbreviations; this times only that
discovery step (not the per-occurrence regex replacement that follows it).

Finding (see analysis/SHORT_STRING_LATENCY_PLAN.md §2a): our pure-Python
automaton is faster on short input; the `in` loop only overtakes around
~100-150 chars. So the scan is *not* the short-string bottleneck — it is ~3% of
the call — and must not be replaced.

Run with:
uv run python benchmarks/abbr_scan_compare.py
"""

from __future__ import annotations

import time

from sentencesplit.abbreviation_replacer import _AbbreviationData
from sentencesplit.languages import LANGUAGE_CODES

_MEDIUM = (
"dr. smith went to washington. he arrived on jan. 5th at 3 p.m. the model is gpt 3.1 and it is fast. "
"that is all for now. goodbye. she paid $4.50 for the u.s. edition (vol. 2, p. 17). mr. lee agreed."
)
CASES = {
"tiny (15c)": "dr. smith left.",
"short (87c)": "dr. smith went to washington. he arrived on jan. 5th at 3 p.m. and met with sen. jones.",
"medium (198c)": _MEDIUM,
"large (4k)": " ".join([_MEDIUM] * 20),
"huge (40k)": " ".join([_MEDIUM] * 200),
}


def _bench(fn, text: str) -> float:
iters = 20000 if len(text) < 300 else (3000 if len(text) < 6000 else 300)
for _ in range(50):
fn(text)
t0 = time.perf_counter()
for _ in range(iters):
fn(text)
return (time.perf_counter() - t0) / iters * 1e6 # us/call


def main() -> None:
data = _AbbreviationData(LANGUAGE_CODES["en"].Abbreviation)
automaton = data.automaton
# The automaton is keyed on "<abbr>." (the trailing-period pre-filter), except
# 'i'-ending abbreviations which keep the bare key (U+0130 'İ' caveat). Mirror
# that here so the naive loop matches the automaton's match set.
abbr_keys = [a[1] if a[1].endswith("i") else a[1] + "." for a in data.abbreviations]

def ac_scan(text: str) -> set[int]:
return automaton.search(text)

def in_loop(text: str) -> set[int]:
return {i for i, a in enumerate(abbr_keys) if a in text}

print(f"abbreviation discovery: Aho-Corasick vs `in` loop ({len(abbr_keys)} patterns)")
print("=" * 60)
print(f"{'input':<16}{'AC us':>10}{'in us':>10}{'winner':>10}{'ratio':>8}")
print("-" * 60)
for name, text in CASES.items():
assert ac_scan(text) == in_loop(text), f"match-set mismatch for {name}"
ac = _bench(ac_scan, text)
inf = _bench(in_loop, text)
winner = "AC" if ac < inf else "in"
print(f"{name:<16}{ac:>10.2f}{inf:>10.2f}{winner:>10}{inf / ac:>8.2f}")
print("-" * 60)
print("ratio = in/AC (>1 means AC faster). Match-sets verified identical.")


if __name__ == "__main__":
main()
132 changes: 132 additions & 0 deletions benchmarks/differential_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Differential profiler: sentencesplit vs pySBD on identical input.

Answers "what *exactly* makes us slower than pySBD per call" by profiling both
on the same text and surfacing the operation-level deltas that matter:

* per-call wall time (ours vs pySBD),
* count + time of every regex op (sub / findall / search / finditer / split)
per call — the clearest "we run more passes" signal,
* total time spent inside the `re` module,
* the top tottime functions for each library, side by side.

Run with:
uv run python benchmarks/differential_profile.py --size short
uv run python benchmarks/differential_profile.py --size medium --top 18
"""

from __future__ import annotations

import argparse
import cProfile
import pstats
import time
from collections import Counter

import pysbd

import sentencesplit

SHORT = "Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. and met with Sen. Jones."
MEDIUM = (
"Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. "
"The model is GPT 3.1 and it is fast. That is all for now. Goodbye. "
"She paid $4.50 for the U.S. edition (vol. 2, p. 17). Mr. Lee agreed."
)
LARGE = " ".join([MEDIUM] * 20)
_SAMPLES = {"short": SHORT, "medium": MEDIUM, "large": LARGE}

_REGEX_OPS = ("sub", "findall", "search", "match", "finditer", "split", "fullmatch")


def _walltime(fn, text: str, iters: int) -> float:
for _ in range(20):
fn(text)
t0 = time.perf_counter()
for _ in range(iters):
fn(text)
return (time.perf_counter() - t0) / iters * 1e6 # us/call


def _profile(fn, text: str, iters: int) -> pstats.Stats:
pr = cProfile.Profile()
for _ in range(5):
fn(text)
pr.enable()
for _ in range(iters):
fn(text)
pr.disable()
return pstats.Stats(pr)


def _regex_op_summary(stats: pstats.Stats, iters: int) -> tuple[Counter, float]:
"""Per-call regex op counts and time spent in the regex engine.

Counts ONLY the compiled-Pattern method calls (e.g. ``<method 'sub' of
're.Pattern' objects>``), not the module-level ``re.sub``/``re.findall``
wrapper frames that delegate to them. Counting both double-counts an
uncompiled ``re.sub(str, ...)`` — which would bias a library that does not
pre-compile (it routes through the wrapper) against one that does.
"""
counts: Counter = Counter()
re_time = 0.0
for (_filename, _lineno, funcname), (_cc, nc, tt, _ct, _cb) in stats.stats.items():
if funcname.startswith("<method '") and "re.Pattern" in funcname:
for op in _REGEX_OPS:
if f"'{op}'" in funcname:
counts[op] += nc
re_time += tt
break
per_call = Counter({k: v / iters for k, v in counts.items()})
return per_call, re_time / iters * 1e6


def _top(stats: pstats.Stats, n: int, iters: int) -> list[tuple[str, float, int]]:
rows = []
for (filename, lineno, funcname), (_cc, nc, tt, _ct, _cb) in stats.stats.items():
short = filename.replace("\\", "/").split("/")[-1]
rows.append((f"{short}:{lineno}:{funcname}", tt / iters * 1e6, nc // iters))
rows.sort(key=lambda r: r[1], reverse=True)
return rows[:n]


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--size", choices=["short", "medium", "large"], default="short")
ap.add_argument("--top", type=int, default=14)
args = ap.parse_args()

text = _SAMPLES[args.size]
iters = 8000 if args.size != "large" else 1000

ours = sentencesplit.Segmenter(language="en", clean=False, char_span=False)
sbd = pysbd.Segmenter(language="en", clean=False)
engines = {"sentencesplit": ours.segment, "pysbd": sbd.segment}

print(f"differential profile size={args.size} ({len(text)} chars) iters={iters}")
print("=" * 72)
walls = {name: _walltime(fn, text, iters) for name, fn in engines.items()}
print(
f"wall time: ours {walls['sentencesplit']:.2f} us/call "
f"pysbd {walls['pysbd']:.2f} us/call "
f"ours is {walls['sentencesplit'] / walls['pysbd']:.2f}x pysbd"
)
print()

for name, fn in engines.items():
stats = _profile(fn, text, iters)
ops, re_us = _regex_op_summary(stats, iters)
total_ops = sum(ops.values())
print(f"--- {name} ---")
print(
f" regex ops/call: {total_ops:.1f} "
f"({' '.join(f'{k}={v:.1f}' for k, v in sorted(ops.items(), key=lambda x: -x[1]))})"
)
print(f" time in re/call: {re_us:.1f} us")
print(f" top {args.top} by tottime (us/call, calls/call):")
for label, us, calls in _top(stats, args.top, iters):
print(f" {us:7.2f} x{calls:<4} {label}")
print()


if __name__ == "__main__":
main()
127 changes: 127 additions & 0 deletions benchmarks/phase_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Phase-level latency profiler for the segmentation pipeline.

Where ``latency_baseline.py`` answers "how fast is a call?", this answers "which
*phase* of the call is the time in?" — it wraps each Processor / AbbreviationReplacer
/ Segmenter stage with a timer and reports per-phase wall time, call count, and
share of the total, for short / medium / large input.

This is the exploration harness for the short/medium-vs-pySBD latency gap: the
fixed per-call cost (the phases that run on every call regardless of input) is
what dominates short strings, so this attributes that cost to concrete phases.

Run with:
uv run python benchmarks/phase_profile.py
uv run python benchmarks/phase_profile.py --size short --iters 20000
"""

from __future__ import annotations

import argparse
import time
from collections import defaultdict

from sentencesplit import Segmenter
from sentencesplit.abbreviation_replacer import AbbreviationReplacer
from sentencesplit.processor import Processor
from sentencesplit.segmenter import Segmenter as _Seg

SHORT = "Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. and met with Sen. Jones."
MEDIUM = (
"Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. "
"The model is GPT 3.1 and it is fast. That is all for now. Goodbye. "
"She paid $4.50 for the U.S. edition (vol. 2, p. 17). Mr. Lee agreed."
)
LARGE = " ".join([MEDIUM] * 20)
_SAMPLES = {"short": SHORT, "medium": MEDIUM, "large": LARGE}

# (class, method) pairs to attribute. Grouped by pipeline stage; the label is what
# the report prints. Only methods that exist are wrapped.
_TARGETS = [
# --- text-processing phases (run once per call over the whole text) ---
(Processor, "_normalize_newlines", "text: normalize_newlines"),
(Processor, "_mark_list_item_boundaries", "text: list_item_boundaries"),
(Processor, "replace_abbreviations", "text: replace_abbreviations"),
(Processor, "replace_numbers", "text: replace_numbers"),
(Processor, "replace_continuous_punctuation", "text: continuous_punct"),
(Processor, "replace_periods_before_numeric_references", "text: numeric_refs"),
(Processor, "_protect_special_tokens", "text: special_tokens"),
# --- boundary-processing phases (per segment) ---
(Processor, "_ensure_terminal_marker", "bound: terminal_marker"),
(Processor, "_apply_exclamation_word_rules", "bound: exclamation_words"),
(Processor, "between_punctuation", "bound: between_punctuation"),
(Processor, "_apply_double_punctuation_rules", "bound: double_punct"),
(Processor, "_apply_quotation_punctuation_rules", "bound: quotation_punct"),
(Processor, "_replace_list_parens", "bound: list_parens"),
(Processor, "sentence_boundary_punctuation", "bound: sentence_boundary"),
# --- post-split passes ---
(Processor, "split_into_segments", "post: split_into_segments (incl. boundary)"),
(Processor, "_resplit_segments", "post: resplit_segments"),
(Processor, "_merge_orphan_fragments", "post: merge_orphans"),
# --- abbreviation internals (the suspected fixed cost) ---
(AbbreviationReplacer, "replace", "abbr: replace (whole)"),
(AbbreviationReplacer, "search_for_abbreviations_in_string", "abbr: search_in_string"),
(AbbreviationReplacer, "apply_ampm_boundary_rules", "abbr: ampm_rules"),
# --- span mapping (segmenter) ---
(_Seg, "_match_spans", "span: match_spans"),
]

_stats: dict[str, list[float | int]] = defaultdict(lambda: [0.0, 0]) # label -> [total_s, calls]


def _wrap(cls, method_name: str, label: str) -> None:
original = getattr(cls, method_name, None)
if original is None:
return

def timed(self, *args, _orig=original, _label=label, **kwargs):
t0 = time.perf_counter()
try:
return _orig(self, *args, **kwargs)
finally:
rec = _stats[_label]
rec[0] += time.perf_counter() - t0
rec[1] += 1

setattr(cls, method_name, timed)


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--size", choices=["short", "medium", "large"], default="short")
ap.add_argument("--iters", type=int, default=20000)
args = ap.parse_args()

for cls, name, label in _TARGETS:
_wrap(cls, name, label)

seg = Segmenter(language="en", clean=False, char_span=False)
text = _SAMPLES[args.size]
for _ in range(5):
seg.segment(text)
_stats.clear()

t0 = time.perf_counter()
for _ in range(args.iters):
seg.segment(text)
total = time.perf_counter() - t0

print(f"phase profile size={args.size} iters={args.iters} ({len(text)} chars)")
print(f"total: {total * 1000 / args.iters:.4f} ms/call ({total:.2f}s)")
print("=" * 78)
print(f"{'phase':<46}{'ms/call':>10}{'calls/seg':>10}{'% total':>10}")
print("-" * 78)
rows = sorted(_stats.items(), key=lambda kv: kv[1][0], reverse=True)
for label, (total_s, calls) in rows:
ms_per_call = total_s * 1000 / args.iters
calls_per_seg = calls / args.iters
pct = 100 * total_s / total
# split_into_segments wraps the boundary phases, so its % overlaps them;
# mark it so the breakdown is not misread as additive.
note = " *wrapper" if "split_into_segments" in label or label == "abbr: replace (whole)" else ""
print(f"{label:<46}{ms_per_call:>10.4f}{calls_per_seg:>10.1f}{pct:>9.1f}%{note}")
print("-" * 78)
print("* wrapper rows contain the rows below them; do not sum across them.")


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