From 8cf730d8ddc0114917a7087d518f8a1ab821b6d2 Mon Sep 17 00:00:00 2001 From: Red Davies Date: Thu, 18 Jun 2026 11:45:43 -0400 Subject: [PATCH 1/3] bench: pony_bench-based benchmark suite for the 0.2.0 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes a performance baseline before encoding work begins in 0.3.0 and so that implementation tweaks have attributable numbers. Layout mirrors our existing build-tool split: unicode_bench/ package: harness, corpora, 15 topic primitives _bench.pony BenchSizes / _Overhead / _Bench / _ValBench / _CtorBench / _BenchPair / _BenchU32 _corpora.pony 7 generators: ascii / latin_precomposed / latin_decomposed / cjk / mixed / emoji / combining_marks bench_*.pony 15 topic registrations (Bytes, Text, Codepoints, Graphemes, Words, Sentences, Lines, Normalize, Case, Compares, Search, Split, Trim, Replace, Scripts) unicode_bench_main/ binary entry point — actor Main is main.pony BenchmarkList; registers every topic against PonyBench in create. The harness adapts the working pieces of the sibling `string_benchmark` project — size buckets (16B → 160M), the per-bucket `cfg(size)` that scales samples and iteration caps so large sizes don't generate gigabytes of garbage per sample, the config-aware overhead bench. New wrappers: `_BenchPair` for binary ops (`Compares.equal_*`, `Search.contains`), `_BenchU32` for single-cp property lookups. Each `bench_*` topic exposes a public primitive with a `register(bench: PonyBench, sizes: Array[USize] val)` method. Sizes are passed in from main so smoke (single small bucket) and full-suite runs share the same registration code. Makefile targets (NOT part of `ci`; benchmark runtimes are far too long for every PR): make bench-build release-mode compile make bench full suite make bench-smoke --smoke single small bucket make bench-csv -csv into build/bench-.csv Validated end-to-end on the smoke bucket. Spot-check from the run: Bytes.is_valid_utf8/ascii/1K ≈ 4.7µs vs cjk/1K ≈ 5.5µs — the 15% delta from multi-byte UTF-8 work confirms the harness measures real cost rather than noise. Pony / LLVM gotchas worked around during bring-up: `DoNotOptimise` generic specialization fails on `(USize, USize)` tuples (LLVM crash) and on unions containing `String iso^` ephemeral types. Tuple cases extract `(let lo, let hi)` then pass the sum; iso cases use `(String iso | InvalidUtf8)` without the ephemeral marker. --- CHANGELOG.md | 1 + Makefile | 30 ++++- unicode_bench/_bench.pony | 198 ++++++++++++++++++++++++++++ unicode_bench/_corpora.pony | 115 ++++++++++++++++ unicode_bench/bench_bytes.pony | 50 +++++++ unicode_bench/bench_case.pony | 55 ++++++++ unicode_bench/bench_codepoints.pony | 93 +++++++++++++ unicode_bench/bench_compares.pony | 65 +++++++++ unicode_bench/bench_graphemes.pony | 58 ++++++++ unicode_bench/bench_lines.pony | 53 ++++++++ unicode_bench/bench_normalize.pony | 77 +++++++++++ unicode_bench/bench_replace.pony | 45 +++++++ unicode_bench/bench_scripts.pony | 66 ++++++++++ unicode_bench/bench_search.pony | 69 ++++++++++ unicode_bench/bench_sentences.pony | 39 ++++++ unicode_bench/bench_split.pony | 34 +++++ unicode_bench/bench_text.pony | 53 ++++++++ unicode_bench/bench_trim.pony | 37 ++++++ unicode_bench/bench_words.pony | 45 +++++++ unicode_bench_main/main.pony | 47 +++++++ 20 files changed, 1229 insertions(+), 1 deletion(-) create mode 100644 unicode_bench/_bench.pony create mode 100644 unicode_bench/_corpora.pony create mode 100644 unicode_bench/bench_bytes.pony create mode 100644 unicode_bench/bench_case.pony create mode 100644 unicode_bench/bench_codepoints.pony create mode 100644 unicode_bench/bench_compares.pony create mode 100644 unicode_bench/bench_graphemes.pony create mode 100644 unicode_bench/bench_lines.pony create mode 100644 unicode_bench/bench_normalize.pony create mode 100644 unicode_bench/bench_replace.pony create mode 100644 unicode_bench/bench_scripts.pony create mode 100644 unicode_bench/bench_search.pony create mode 100644 unicode_bench/bench_sentences.pony create mode 100644 unicode_bench/bench_split.pony create mode 100644 unicode_bench/bench_text.pony create mode 100644 unicode_bench/bench_trim.pony create mode 100644 unicode_bench/bench_words.pony create mode 100644 unicode_bench_main/main.pony diff --git a/CHANGELOG.md b/CHANGELOG.md index f84ff0b..4d32d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. This projec ### Added +- `pony_bench`-based benchmark suite under `unicode_bench/` + `unicode_bench_main/` covering every 0.2.0 hot path: `Bytes` validation, `Text` construction (plain and indexed), `Codepoints` property lookups + counts, all four segmentation cursors (`Graphemes`/`Words`/`Sentences`/`Lines`), the four normal forms, case mappings, the five comparison flavours, `Search`/`Split`/`Trim`/`Replace`, and the `Scripts` ops. Seven canonical input corpora — ASCII, Latin precomposed, Latin decomposed, CJK, mixed, emoji, and a pathological combining-marks generator — let each topic exercise its representative shape. Per-size iteration caps keep the run tractable at 160M-byte inputs. New Makefile targets `bench-build` / `bench` (full release-mode run) / `bench-smoke` (single small bucket) / `bench-csv` (machine-readable for cross-run diffing). Not part of `ci`. - `Codepoint` class methods: `script()`, `script_extensions()`, `has_property(p)`, `east_asian_width()` — symmetric with the existing `Codepoints.*` primitive accessors - Auto-generated `_UcdScriptExtensions` lookup table from `ScriptExtensions.txt` (UAX #24, codepoints used in more than one script). Resolved via `PropertyValueAliases.txt` so the short codes in ScriptExtensions.txt (`Latn`, `Bopo`, …) map to the same `Script` byte encoding as `_UcdScript` - `Codepoints.script_extensions(u): Array[Script] val` — falls back to `[script(u)]` for codepoints not listed in ScriptExtensions.txt diff --git a/Makefile b/Makefile index 466c234..a4fc226 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ $(BUILD_DIR): dependencies: corral.json $(GET_DEPENDENCIES_WITH) -.PHONY: all ci test unit-tests clean realclean dependencies docs ucd-build ucd-generate ucd-download conform conform-build conform-grapheme conform-grapheme-build conform-word conform-word-build conform-sentence conform-sentence-build conform-line conform-line-build +.PHONY: all ci test unit-tests clean realclean dependencies docs ucd-build ucd-generate ucd-download conform conform-build conform-grapheme conform-grapheme-build conform-word conform-word-build conform-sentence conform-sentence-build conform-line conform-line-build bench bench-build bench-smoke bench-csv .DEFAULT_GOAL := all @@ -187,3 +187,31 @@ $(conform_line_binary): $(SOURCE_FILES) $(shell find unicode_line_conform_main - conform-line: $(conform_line_binary) $(conform_line_binary) $(UCD_DIR)/auxiliary/LineBreakTest.txt + +# ---------- Benchmarks ---------- +# `make bench` runs the full pony_bench-based suite under release +# optimization; `bench-smoke` runs a single small bucket for quick +# verification; `bench-csv` writes machine-readable output for cross-run +# diffing. Benchmarks are NOT part of `ci` — runtimes are too long for +# every PR. + +BENCH_BUILD_DIR := build/release +bench_binary := $(BENCH_BUILD_DIR)/unicode_bench_main + +bench-build: $(bench_binary) + +$(bench_binary): $(SOURCE_FILES) $(shell find unicode_bench unicode_bench_main -name '*.pony' 2>/dev/null) | $(BENCH_BUILD_DIR) dependencies + $(COMPILE_WITH) -o $(BENCH_BUILD_DIR) unicode_bench_main -b unicode_bench_main + +$(BENCH_BUILD_DIR): + mkdir -p $(BENCH_BUILD_DIR) + +bench: $(bench_binary) + $(bench_binary) --ponynoyield + +bench-smoke: $(bench_binary) + $(bench_binary) --ponynoyield --smoke + +bench-csv: $(bench_binary) | $(BUILD_DIR) + $(bench_binary) --ponynoyield -csv > $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv + @echo "wrote $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv" diff --git a/unicode_bench/_bench.pony b/unicode_bench/_bench.pony new file mode 100644 index 0000000..32c0cc2 --- /dev/null +++ b/unicode_bench/_bench.pony @@ -0,0 +1,198 @@ +use "pony_bench" + +primitive BenchSizes + """ + Size buckets every size-sensitive benchmark is run across, plus helpers + for labelling and tuning the per-bucket iteration budget. Same layout + as the sibling `string_benchmark` project so cross-comparisons line up. + """ + fun apply(): Array[USize] val => + [as USize: 16; 160; 1_600; 16_000; 160_000; 1_600_000; 16_000_000; 160_000_000] + + fun smoke(): Array[USize] val => + """ + Single small bucket — used by the smoke build to confirm the harness + boots and a benchmark completes before exercising the full size range. + """ + [as USize: 1_600] + + fun label(size: USize): String => + if size >= 1_048_576 then (size / 1_048_576).string() + "M" + elseif size >= 1_024 then (size / 1_024).string() + "K" + else size.string() + "B" + end + + fun samples(): USize => 20 + fun sample_ns(): U64 => 30_000_000 + + fun default_cfg(): BenchConfig => + BenchConfig(where samples' = samples(), max_sample_time' = sample_ns()) + + fun cfg(size: USize): BenchConfig => + """ + Allocating benchmarks accumulate per-iteration garbage inside a single + sample. Bound iterations so large sizes don't generate gigabytes of + garbage before GC runs. Mirrors the string_benchmark calibration. + """ + let max_it: U64 = + if size >= 1_048_576 then 1_024 + elseif size >= 65_536 then 4_096 + elseif size >= 4_096 then 100_000 + else 1_000_000 + end + let s: USize = + if size >= 1_048_576 then samples().min(5) + elseif size >= 65_536 then samples().min(10) + else samples() + end + BenchConfig(where + samples' = s, + max_iterations' = max_it, + max_sample_time' = sample_ns()) + +class iso _Overhead is MicroBenchmark + """ + Overhead benchmark sized to the actual benchmark's config so the + subtraction uses the same sample budget. pony_bench's built-in + `OverheadBenchmark` always uses the default 20-sample/100ms config, + which is far larger than the smallest buckets need. + """ + let _config: BenchConfig + new iso create(config': BenchConfig) => _config = config' + fun name(): String => "Benchmark Overhead" + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() => + DoNotOptimise[None](None) + DoNotOptimise.observe() + +class iso _Bench is MicroBenchmark + """ + Read-only benchmark (box-receiver). The subject is built once and never + mutated, so no per-iteration reset is needed. + """ + let _name: String + let _s: String val + let _op: {(String box) ?} val + let _config: BenchConfig + + new iso create( + name': String, + s: String val, + op: {(String box) ?} val, + config': BenchConfig = BenchSizes.default_cfg()) + => + _name = name' + _s = s + _op = op + _config = config' + + fun name(): String => _name + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() ? => _op(_s)? + +class iso _ValBench is MicroBenchmark + """ + Val-receiver benchmark — for ops that take `String val` (e.g. + `Text.from_iso_string` returns iso but most ops want box; this is for + the cases where the iso input is consumed). + """ + let _name: String + let _s: String val + let _op: {(String val) ?} val + let _config: BenchConfig + + new iso create( + name': String, + s: String val, + op: {(String val) ?} val, + config': BenchConfig = BenchSizes.default_cfg()) + => + _name = name' + _s = s + _op = op + _config = config' + + fun name(): String => _name + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() ? => _op(_s)? + +class iso _CtorBench is MicroBenchmark + """ + Constructor benchmark. The op captures whatever (val) inputs it needs + and builds + observes a fresh result each iteration. + """ + let _name: String + let _op: {() ?} val + let _config: BenchConfig + + new iso create( + name': String, + op: {() ?} val, + config': BenchConfig = BenchSizes.default_cfg()) + => + _name = name' + _op = op + _config = config' + + fun name(): String => _name + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() ? => _op()? + +class iso _BenchPair is MicroBenchmark + """ + Pair-input benchmark for binary ops like `Compares.equal_*(a, b)` or + `Search.contains(haystack, needle)`. Both strings are built once. + """ + let _name: String + let _a: String val + let _b: String val + let _op: {(String box, String box) ?} val + let _config: BenchConfig + + new iso create( + name': String, + a: String val, + b: String val, + op: {(String box, String box) ?} val, + config': BenchConfig = BenchSizes.default_cfg()) + => + _name = name' + _a = a + _b = b + _op = op + _config = config' + + fun name(): String => _name + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() ? => _op(_a, _b)? + +class iso _BenchU32 is MicroBenchmark + """ + Single-codepoint benchmark for property lookups (`category`, `script`, + etc.). Carries a U32 directly so the op doesn't pay String boxing. + """ + let _name: String + let _cp: U32 + let _op: {(U32)} val + let _config: BenchConfig + + new iso create( + name': String, + cp: U32, + op: {(U32)} val, + config': BenchConfig = BenchSizes.default_cfg()) + => + _name = name' + _cp = cp + _op = op + _config = config' + + fun name(): String => _name + fun config(): BenchConfig => _config + fun overhead(): MicroBenchmark^ => _Overhead(_config) + fun ref apply() => _op(_cp) diff --git a/unicode_bench/_corpora.pony b/unicode_bench/_corpora.pony new file mode 100644 index 0000000..7bc6d5c --- /dev/null +++ b/unicode_bench/_corpora.pony @@ -0,0 +1,115 @@ +primitive _Corpora + """ + Sample text generators for the benchmarks. Each generator returns a + `String val` whose byte length is at least the requested `size` (and + at most `size + pattern.size()` — never trimmed mid-codepoint). + + All patterns are valid UTF-8. The patterns are picked to exercise + different bytes-per-cp distributions and (where relevant) different + normalization / segmentation states: + + * `ascii` — 1-byte cps, ASCII only + * `latin_precomposed` — Latin-1 / 2-byte cps in NFC + * `latin_decomposed` — same text in NFD; stresses NFC composition + * `cjk` — 3-byte cps, Han ideographs + * `mixed` — ASCII + Latin + CJK in alternation + * `emoji` — 4-byte cps + ZWJ sequences (multi-cp graphemes) + * `combining_marks` — pathological: base + 5 combining marks per cluster + """ + + fun ascii(size: USize): String val => + _repeat(_ascii_pattern(), size) + + fun latin_precomposed(size: USize): String val => + _repeat(_latin_precomposed_pattern(), size) + + fun latin_decomposed(size: USize): String val => + _repeat(_latin_decomposed_pattern(), size) + + fun cjk(size: USize): String val => + _repeat(_cjk_pattern(), size) + + fun mixed(size: USize): String val => + _repeat(_mixed_pattern(), size) + + fun emoji(size: USize): String val => + _repeat(_emoji_pattern(), size) + + fun combining_marks(size: USize): String val => + _repeat(_combining_pattern(), size) + + fun _ascii_pattern(): String val => + "The quick brown fox jumps over the lazy dog. " + + fun _latin_precomposed_pattern(): String val => + "café déjà naïve résumé schöne Straße über Hände " + + fun _latin_decomposed_pattern(): String val => + recover val + let s = String + s.append("cafe"); s.push_utf32(0x0301); s.push(' ') // café + s.append("deja"); s.push_utf32(0x0300); s.push(' ') // déjà (à) + s.append("naive"); s.push_utf32(0x0308); s.push(' ') // naïve (ï) + s.append("resume"); s.push_utf32(0x0301); s.push(' ') // résumé + s.append("schone"); s.push_utf32(0x0308); s.push(' ') // schöne + s.append("Strasse"); s.push_utf32(0x0301); s.push(' ') // (placeholder) + s.append("uber"); s.push_utf32(0x0308); s.push(' ') // über + s.append("Hande"); s.push_utf32(0x0308); s.push(' ') // Hände + s + end + + fun _cjk_pattern(): String val => + "中国汉字测试样本日本語サンプル " + + fun _mixed_pattern(): String val => + "Hello 世界 café 中国 résumé 日本 World " + + fun _emoji_pattern(): String val => + recover val + let s = String + s.push_utf32(0x1F600); s.push(' ') // 😀 (single cp grapheme) + s.push_utf32(0x1F389); s.push(' ') // 🎉 + // Flag of France — Regional_Indicator pair = 1 grapheme. + s.push_utf32(0x1F1EB); s.push_utf32(0x1F1F7); s.push(' ') + // Family ZWJ sequence (man + ZWJ + woman + ZWJ + girl) — 1 grapheme. + s.push_utf32(0x1F468); s.push_utf32(0x200D) + s.push_utf32(0x1F469); s.push_utf32(0x200D) + s.push_utf32(0x1F467); s.push(' ') + s.push_utf32(0x1F680); s.push(' ') // 🚀 + s + end + + fun _combining_pattern(): String val => + """ + Pathological case for NFC composition / canonical-ordering: each + base letter is followed by FIVE combining marks. NFC must read + them all, canonical-order them, then re-compose where possible. + """ + recover val + let s = String + var i: USize = 0 + while i < 8 do + // Latin letter 'a' + 5 combining marks (each above/below). + s.push('a') + s.push_utf32(0x0301) // combining acute (Above) + s.push_utf32(0x0308) // combining diaeresis (Above) + s.push_utf32(0x0323) // combining dot below (Below) + s.push_utf32(0x0327) // combining cedilla (Below_Attached) + s.push_utf32(0x0316) // combining grave below (Below) + i = i + 1 + end + s.push(' ') + s + end + + fun _repeat(pattern: String val, target: USize): String val => + """ + Append `pattern` repeatedly until the buffer reaches at least + `target` bytes. Boundaries are always at codepoint boundaries + because we only append whole patterns. + """ + recover val + let out = String(target + pattern.size()) + while out.size() < target do out.append(pattern) end + out + end diff --git a/unicode_bench/bench_bytes.pony b/unicode_bench/bench_bytes.pony new file mode 100644 index 0000000..50b2c7b --- /dev/null +++ b/unicode_bench/bench_bytes.pony @@ -0,0 +1,50 @@ +use "pony_bench" +use "../unicode" + +primitive BenchBytes + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + + _is_valid(bench, "Bytes.is_valid_utf8/ascii/" + lbl, + _Corpora.ascii(size), cfg) + _is_valid(bench, "Bytes.is_valid_utf8/cjk/" + lbl, + _Corpora.cjk(size), cfg) + _is_valid(bench, "Bytes.is_valid_utf8/emoji/" + lbl, + _Corpora.emoji(size), cfg) + _is_valid(bench, "Bytes.is_valid_utf8/mixed/" + lbl, + _Corpora.mixed(size), cfg) + + _first_bad(bench, "Bytes.first_bad_utf8_offset/ascii/" + lbl, + _Corpora.ascii(size), cfg) + _first_bad(bench, "Bytes.first_bad_utf8_offset/cjk/" + lbl, + _Corpora.cjk(size), cfg) + + fun _is_valid( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[Bool](Bytes.is_valid_utf8(s)) + DoNotOptimise.observe() + }, cfg)) + + fun _first_bad( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(AllValid | USize)](Bytes.first_bad_utf8_offset(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_case.pony b/unicode_bench/bench_case.pony new file mode 100644 index 0000000..d667625 --- /dev/null +++ b/unicode_bench/bench_case.pony @@ -0,0 +1,55 @@ +use "pony_bench" +use "../unicode" + +primitive BenchCase + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let latin = _Corpora.latin_precomposed(size) + let mixed = _Corpora.mixed(size) + + _op(bench, "Case.upper/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.upper(s)) + DoNotOptimise.observe() + }, cfg) + _op(bench, "Case.upper/latin/" + lbl, latin, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.upper(s)) + DoNotOptimise.observe() + }, cfg) + _op(bench, "Case.lower/latin/" + lbl, latin, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.lower(s)) + DoNotOptimise.observe() + }, cfg) + _op(bench, "Case.title/latin/" + lbl, latin, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.title(s)) + DoNotOptimise.observe() + }, cfg) + _op(bench, "Case.fold/latin/" + lbl, latin, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.fold(s)) + DoNotOptimise.observe() + }, cfg) + _op(bench, "Case.fold/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Case.fold(s)) + DoNotOptimise.observe() + }, cfg) + + fun _op( + bench: PonyBench, + name: String, + s: String val, + op: {(String box) ?} val, + cfg: BenchConfig) + => + bench(_Bench(name, s, op, cfg)) diff --git a/unicode_bench/bench_codepoints.pony b/unicode_bench/bench_codepoints.pony new file mode 100644 index 0000000..a88cc2d --- /dev/null +++ b/unicode_bench/bench_codepoints.pony @@ -0,0 +1,93 @@ +use "pony_bench" +use "../unicode" + +primitive BenchCodepoints + fun register(bench: PonyBench, sizes: Array[USize] val) => + // Single-cp property lookups: size-independent — register once. + _single_cp(bench) + + // Whole-string count: size-sensitive. + for size in sizes.values() do + _count_size(bench, size) + end + + fun _single_cp(bench: PonyBench) => + let cfg = BenchSizes.default_cfg() + let cps: Array[(U32, String)] val = + [as (U32, String): + (U32('A'), "ascii") + (U32(0xE9), "latin1") + (U32(0x4E2D), "cjk") + (U32(0x1F600), "emoji") + ] + for c in cps.values() do + (let cp, let lbl) = c + bench(_BenchU32("Codepoints.category/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[Category](Codepoints.category(cp)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.script/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[Script](Codepoints.script(cp)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.script_extensions/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[Array[Script] val]( + Codepoints.script_extensions(cp)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.east_asian_width/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[EastAsianWidth](Codepoints.east_asian_width(cp)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.has_binary_property/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[Bool]( + Codepoints.has_binary_property(cp, PropAlphabetic)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.name/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[(String val | None)](Codepoints.name(cp)) + DoNotOptimise.observe() + }, cfg)) + bench(_BenchU32("Codepoints.combining_class/" + lbl, cp, + {(cp: U32) => + DoNotOptimise[U8](Codepoints.combining_class(cp)) + DoNotOptimise.observe() + }, cfg)) + end + + // Reverse name lookup — linear scan; the slow path. + bench(_CtorBench("Codepoints.from_name", + {() => + DoNotOptimise[(U32 | None)]( + Codepoints.from_name("LATIN CAPITAL LETTER A")) + DoNotOptimise.observe() + }, cfg)) + + fun _count_size(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let cjk = _Corpora.cjk(size) + let mixed = _Corpora.mixed(size) + + bench(_Bench("Codepoints.count/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Codepoints.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Codepoints.count/cjk/" + lbl, cjk, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Codepoints.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Codepoints.count/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Codepoints.count(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_compares.pony b/unicode_bench/bench_compares.pony new file mode 100644 index 0000000..184c0e9 --- /dev/null +++ b/unicode_bench/bench_compares.pony @@ -0,0 +1,65 @@ +use "pony_bench" +use "../unicode" + +primitive BenchCompares + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + + // Worst case for each predicate: both strings are equal under the + // operation, so the comparison has to walk the entire length. + let ascii_a = _Corpora.ascii(size) + let ascii_b = _Corpora.ascii(size) + let latin_a = _Corpora.latin_precomposed(size) + let latin_b = _Corpora.latin_precomposed(size) + + bench(_BenchPair("Compares.bytes/ascii/" + lbl, + ascii_a, ascii_b, + {(a: String box, b: String box) => + DoNotOptimise[Compare](Compares.bytes(a, b)) + DoNotOptimise.observe() + }, cfg)) + + bench(_BenchPair("Compares.equal_bytes/ascii/" + lbl, + ascii_a, ascii_b, + {(a: String box, b: String box) => + DoNotOptimise[Bool](Compares.equal_bytes(a, b)) + DoNotOptimise.observe() + }, cfg)) + + bench(_BenchPair("Compares.equal_canonical/latin/" + lbl, + latin_a, latin_b, + {(a: String box, b: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Compares.equal_canonical(a, b)) + DoNotOptimise.observe() + }, cfg)) + + bench(_BenchPair("Compares.equal_compat/latin/" + lbl, + latin_a, latin_b, + {(a: String box, b: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Compares.equal_compat(a, b)) + DoNotOptimise.observe() + }, cfg)) + + bench(_BenchPair("Compares.equal_caseless/latin/" + lbl, + latin_a, latin_b, + {(a: String box, b: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Compares.equal_caseless(a, b)) + DoNotOptimise.observe() + }, cfg)) + + bench(_BenchPair("Compares.equal_caseless_canonical/latin/" + lbl, + latin_a, latin_b, + {(a: String box, b: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Compares.equal_caseless_canonical(a, b)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_graphemes.pony b/unicode_bench/bench_graphemes.pony new file mode 100644 index 0000000..c276cd7 --- /dev/null +++ b/unicode_bench/bench_graphemes.pony @@ -0,0 +1,58 @@ +use "pony_bench" +use "../unicode" + +primitive BenchGraphemes + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let latin = _Corpora.latin_precomposed(size) + let cjk = _Corpora.cjk(size) + let emoji = _Corpora.emoji(size) + + _count(bench, "Graphemes.count/ascii/" + lbl, ascii, cfg) + _count(bench, "Graphemes.count/latin/" + lbl, latin, cfg) + _count(bench, "Graphemes.count/cjk/" + lbl, cjk, cfg) + _count(bench, "Graphemes.count/emoji/" + lbl, emoji, cfg) + + _ranges(bench, "Graphemes.ranges-walk/ascii/" + lbl, ascii, cfg) + _ranges(bench, "Graphemes.ranges-walk/cjk/" + lbl, cjk, cfg) + _ranges(bench, "Graphemes.ranges-walk/emoji/" + lbl, emoji, cfg) + + fun _count( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Graphemes.count(s)) + DoNotOptimise.observe() + }, cfg)) + + fun _ranges( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + match Graphemes.ranges(s) + | let it: Iterator[(USize, USize)] => + var n: USize = 0 + while it.has_next() do + try (let lo, let hi) = it.next()?; DoNotOptimise[USize](lo + hi) end + n = n + 1 + end + DoNotOptimise[USize](n) + | let _: InvalidUtf8 => None + end + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_lines.pony b/unicode_bench/bench_lines.pony new file mode 100644 index 0000000..1908503 --- /dev/null +++ b/unicode_bench/bench_lines.pony @@ -0,0 +1,53 @@ +use "pony_bench" +use "../unicode" + +primitive BenchLines + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let cjk = _Corpora.cjk(size) + let mixed = _Corpora.mixed(size) + + bench(_Bench("Lines.count/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Lines.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Lines.count/cjk/" + lbl, cjk, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Lines.count(s)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Lines.ranges-walk/ascii/" + lbl, ascii, + {(s: String box) => + match Lines.ranges(s) + | let it: Iterator[(USize, USize)] => + var n: USize = 0 + while it.has_next() do + try (let lo, let hi) = it.next()?; DoNotOptimise[USize](lo + hi) end + n = n + 1 + end + DoNotOptimise[USize](n) + end + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Lines.ranges-walk/mixed/" + lbl, mixed, + {(s: String box) => + match Lines.ranges(s) + | let it: Iterator[(USize, USize)] => + var n: USize = 0 + while it.has_next() do + try (let lo, let hi) = it.next()?; DoNotOptimise[USize](lo + hi) end + n = n + 1 + end + DoNotOptimise[USize](n) + end + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_normalize.pony b/unicode_bench/bench_normalize.pony new file mode 100644 index 0000000..2896094 --- /dev/null +++ b/unicode_bench/bench_normalize.pony @@ -0,0 +1,77 @@ +use "pony_bench" +use "../unicode" + +primitive BenchNormalize + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let latin_pre = _Corpora.latin_precomposed(size) + let latin_dec = _Corpora.latin_decomposed(size) + let combining = _Corpora.combining_marks(size) + + _nfd(bench, "Normalize.nfd/ascii/" + lbl, ascii, cfg) + _nfd(bench, "Normalize.nfd/latin-pre/" + lbl, latin_pre, cfg) + + // NFC composition is the headline workload — decomposed and + // pathological combining-marks corpora are the worst cases. + _nfc(bench, "Normalize.nfc/ascii/" + lbl, ascii, cfg) + _nfc(bench, "Normalize.nfc/latin-pre/" + lbl, latin_pre, cfg) + _nfc(bench, "Normalize.nfc/latin-dec/" + lbl, latin_dec, cfg) + _nfc(bench, "Normalize.nfc/combining/" + lbl, combining, cfg) + + _nfkd(bench, "Normalize.nfkd/latin-pre/" + lbl, latin_pre, cfg) + _nfkc(bench, "Normalize.nfkc/latin-pre/" + lbl, latin_pre, cfg) + + fun _nfd( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Normalize.nfd(s)) + DoNotOptimise.observe() + }, cfg)) + + fun _nfc( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Normalize.nfc(s)) + DoNotOptimise.observe() + }, cfg)) + + fun _nfkd( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Normalize.nfkd(s)) + DoNotOptimise.observe() + }, cfg)) + + fun _nfkc( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Normalize.nfkc(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_replace.pony b/unicode_bench/bench_replace.pony new file mode 100644 index 0000000..cf24bb4 --- /dev/null +++ b/unicode_bench/bench_replace.pony @@ -0,0 +1,45 @@ +use "pony_bench" +use "../unicode" + +primitive BenchReplace + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + + // Dense matches: "the" appears many times in the ascii corpus. + bench(_Bench("Replace.all[dense]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)]( + Replace.all(s, "the", "THE")) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Replace.first[dense]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)]( + Replace.first(s, "the", "THE")) + DoNotOptimise.observe() + }, cfg)) + + // No matches: must walk the whole string for a needle that isn't + // there. + bench(_Bench("Replace.all[absent]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)]( + Replace.all(s, "NOMATCH_XYZ", "anything")) + DoNotOptimise.observe() + }, cfg)) + + // Same-size replacement vs growing replacement (different copying + // strategies in Replace.all). + bench(_Bench("Replace.all[grow]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)]( + Replace.all(s, "the", "th_e_")) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_scripts.pony b/unicode_bench/bench_scripts.pony new file mode 100644 index 0000000..6eca92b --- /dev/null +++ b/unicode_bench/bench_scripts.pony @@ -0,0 +1,66 @@ +use "pony_bench" +use "../unicode" + +primitive BenchScripts + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let cjk = _Corpora.cjk(size) + let mixed = _Corpora.mixed(size) + + bench(_Bench("Scripts.of/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[ScriptSet val](Scripts.of(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Scripts.of/cjk/" + lbl, cjk, + {(s: String box) => + DoNotOptimise[ScriptSet val](Scripts.of(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Scripts.of/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[ScriptSet val](Scripts.of(s)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Scripts.dominant/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[Script](Scripts.dominant(s)) + DoNotOptimise.observe() + }, cfg)) + + let allowed = ScriptSet.create([as Script: ScriptLatin]) + bench(_Bench("Scripts.restrict_to[Latin-only]/ascii/" + lbl, ascii, + {(s: String box)(allowed) => + DoNotOptimise[Bool](Scripts.restrict_to(s, allowed)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Scripts.restrict_to[Latin-only]/mixed/" + lbl, mixed, + {(s: String box)(allowed) => + DoNotOptimise[Bool](Scripts.restrict_to(s, allowed)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Scripts.is_single_script/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[Bool](Scripts.is_single_script(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Scripts.is_single_script/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[Bool](Scripts.is_single_script(s)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Scripts.resolved_script_set/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[ScriptSet val](Scripts.resolved_script_set(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_search.pony b/unicode_bench/bench_search.pony new file mode 100644 index 0000000..1938de2 --- /dev/null +++ b/unicode_bench/bench_search.pony @@ -0,0 +1,69 @@ +use "pony_bench" +use "../unicode" + +primitive BenchSearch + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let cjk = _Corpora.cjk(size) + + // Three cases per op: needle absent (worst-case for `contains`/ + // `index_of`), present early, present at the very end. + bench(_Bench("Search.contains[absent]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Search.contains(s, "NOMATCH_XYZ_123")) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Search.contains[present]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Search.contains(s, "quick")) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Search.index_of[absent]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | None | InvalidUtf8)]( + Search.index_of(s, "NOMATCH")) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Search.last_index_of[absent]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | None | InvalidUtf8)]( + Search.last_index_of(s, "NOMATCH")) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Search.count[dense]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Search.count(s, "the")) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Search.starts_with/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Search.starts_with(s, "The")) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Search.ends_with/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Search.ends_with(s, " ")) + DoNotOptimise.observe() + }, cfg)) + + // Multi-byte needle search. + bench(_Bench("Search.contains[present]/cjk/" + lbl, cjk, + {(s: String box) => + DoNotOptimise[(Bool | InvalidUtf8)]( + Search.contains(s, "中国")) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_sentences.pony b/unicode_bench/bench_sentences.pony new file mode 100644 index 0000000..300aa32 --- /dev/null +++ b/unicode_bench/bench_sentences.pony @@ -0,0 +1,39 @@ +use "pony_bench" +use "../unicode" + +primitive BenchSentences + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let mixed = _Corpora.mixed(size) + + bench(_Bench("Sentences.count/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Sentences.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Sentences.count/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Sentences.count(s)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Sentences.ranges-walk/ascii/" + lbl, ascii, + {(s: String box) => + match Sentences.ranges(s) + | let it: Iterator[(USize, USize)] => + var n: USize = 0 + while it.has_next() do + try (let lo, let hi) = it.next()?; DoNotOptimise[USize](lo + hi) end + n = n + 1 + end + DoNotOptimise[USize](n) + end + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_split.pony b/unicode_bench/bench_split.pony new file mode 100644 index 0000000..9c672fc --- /dev/null +++ b/unicode_bench/bench_split.pony @@ -0,0 +1,34 @@ +use "pony_bench" +use "../unicode" + +primitive BenchSplit + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + + bench(_Bench("Split.on[space]/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(Array[String val] val | InvalidUtf8)]( + Split.on(s, " ")) + DoNotOptimise.observe() + }, cfg)) + + // Lines: build a corpus with explicit \n separators. + let with_nl = recover val + let out = String(size + 64) + out.append(ascii) + out.append("\nsecond line\nthird line\r\nfourth\r\n") + out + end + bench(_Bench("Split.lines/" + lbl, with_nl, + {(s: String box) => + DoNotOptimise[(Array[String val] val | InvalidUtf8)]( + Split.lines(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_text.pony b/unicode_bench/bench_text.pony new file mode 100644 index 0000000..85c929a --- /dev/null +++ b/unicode_bench/bench_text.pony @@ -0,0 +1,53 @@ +use "pony_bench" +use "../unicode" + +primitive BenchText + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let cjk = _Corpora.cjk(size) + let mixed = _Corpora.mixed(size) + let emoji = _Corpora.emoji(size) + + _from_string(bench, "Text.from_string/ascii/" + lbl, ascii, cfg) + _from_string(bench, "Text.from_string/cjk/" + lbl, cjk, cfg) + _from_string(bench, "Text.from_string/mixed/" + lbl, mixed, cfg) + _from_string(bench, "Text.from_string/emoji/" + lbl, emoji, cfg) + + _from_string_indexed(bench, + "Text.from_string[indexed]/ascii/" + lbl, ascii, cfg) + _from_string_indexed(bench, + "Text.from_string[indexed]/cjk/" + lbl, cjk, cfg) + _from_string_indexed(bench, + "Text.from_string[indexed]/emoji/" + lbl, emoji, cfg) + + fun _from_string( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) ? => + DoNotOptimise[Text val](Text.from_string(s.clone())?) + DoNotOptimise.observe() + }, cfg)) + + fun _from_string_indexed( + bench: PonyBench, + name: String, + s: String val, + cfg: BenchConfig) + => + bench(_Bench(name, s, + {(s: String box) ? => + DoNotOptimise[Text val]( + Text.from_string(s.clone() where indexed = true)?) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_trim.pony b/unicode_bench/bench_trim.pony new file mode 100644 index 0000000..8359099 --- /dev/null +++ b/unicode_bench/bench_trim.pony @@ -0,0 +1,37 @@ +use "pony_bench" +use "../unicode" + +primitive BenchTrim + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + + // Construct a padded input: whitespace at both ends, content in middle. + let padded = recover val + let out = String(size + 64) + out.append("\t\n ") + out.append(_Corpora.ascii(size)) + out.append(" \n\t") + out + end + + bench(_Bench("Trim.trim/" + lbl, padded, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Trim.trim(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Trim.trim_start/" + lbl, padded, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Trim.trim_start(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Trim.trim_end/" + lbl, padded, + {(s: String box) => + DoNotOptimise[(String iso | InvalidUtf8)](Trim.trim_end(s)) + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench/bench_words.pony b/unicode_bench/bench_words.pony new file mode 100644 index 0000000..6af60a2 --- /dev/null +++ b/unicode_bench/bench_words.pony @@ -0,0 +1,45 @@ +use "pony_bench" +use "../unicode" + +primitive BenchWords + fun register(bench: PonyBench, sizes: Array[USize] val) => + for size in sizes.values() do + _one(bench, size) + end + + fun _one(bench: PonyBench, size: USize) => + let cfg = BenchSizes.cfg(size) + let lbl = BenchSizes.label(size) + let ascii = _Corpora.ascii(size) + let mixed = _Corpora.mixed(size) + let cjk = _Corpora.cjk(size) + + bench(_Bench("Words.count/ascii/" + lbl, ascii, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Words.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Words.count/mixed/" + lbl, mixed, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Words.count(s)) + DoNotOptimise.observe() + }, cfg)) + bench(_Bench("Words.count/cjk/" + lbl, cjk, + {(s: String box) => + DoNotOptimise[(USize | InvalidUtf8)](Words.count(s)) + DoNotOptimise.observe() + }, cfg)) + + bench(_Bench("Words.ranges-walk/ascii/" + lbl, ascii, + {(s: String box) => + match Words.ranges(s) + | let it: Iterator[(USize, USize)] => + var n: USize = 0 + while it.has_next() do + try (let lo, let hi) = it.next()?; DoNotOptimise[USize](lo + hi) end + n = n + 1 + end + DoNotOptimise[USize](n) + end + DoNotOptimise.observe() + }, cfg)) diff --git a/unicode_bench_main/main.pony b/unicode_bench_main/main.pony new file mode 100644 index 0000000..6d3ab14 --- /dev/null +++ b/unicode_bench_main/main.pony @@ -0,0 +1,47 @@ +use "pony_bench" +use "../unicode" +use "../unicode_bench" + +actor Main is BenchmarkList + """ + Entry point for the unicode benchmark suite. Each topic primitive + (BenchBytes, BenchText, …) registers its benchmarks against the + `PonyBench` actor in `create`. `benchmarks(bench)` is a no-op — all + registration happens here in `create` because we want corpora prepared + once and then handed to each topic. + + Run modes: + `./unicode_bench_main` — full suite (every size bucket) + `./unicode_bench_main --smoke` — single small bucket only + `./unicode_bench_main -csv` — machine-readable CSV (pony_bench flag) + + Recommended ponyc flag: `--ponynoyield` (see pony_bench docs). Release + builds matter — the debug build is 10× slower and only useful for + shaking out compile errors. + """ + new create(env: Env) => + var smoke: Bool = false + for a in env.args.values() do + if a == "--smoke" then smoke = true end + end + let pb = PonyBench(env, this) + let sizes = if smoke then BenchSizes.smoke() else BenchSizes() end + + BenchBytes.register(pb, sizes) + BenchText.register(pb, sizes) + BenchCodepoints.register(pb, sizes) + BenchGraphemes.register(pb, sizes) + BenchWords.register(pb, sizes) + BenchSentences.register(pb, sizes) + BenchLines.register(pb, sizes) + BenchNormalize.register(pb, sizes) + BenchCase.register(pb, sizes) + BenchCompares.register(pb, sizes) + BenchSearch.register(pb, sizes) + BenchSplit.register(pb, sizes) + BenchTrim.register(pb, sizes) + BenchReplace.register(pb, sizes) + BenchScripts.register(pb, sizes) + + fun tag benchmarks(bench: PonyBench) => + None From 8b27ec0a87e5be8a8076d65e8eebfc71d9291962 Mon Sep 17 00:00:00 2001 From: Red Davies Date: Thu, 18 Jun 2026 12:33:01 -0400 Subject: [PATCH 2/3] bench: cap pathologically slow ops at 16M (skip the 160M bucket) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operations cost minutes per single iteration at the 160M bucket, which makes the suite intractable for routine before/after diffing: - `Text.from_string[indexed]` builds an O(n) grapheme bitmap on top of validation. From the partial CSV: ~750 ns/byte at the 15M bucket, projecting to ~2 min per iter at 160M; with 5 samples + the paired overhead bench, ~30-45 min per Text-indexed bench triple. - `Normalize.nfc/combining` runs the canonical reorder over a 6-codepoint combining run per base letter, then composes. Same shape — likely 10+ min per bench at 160M. `BenchSizes.heavy_op_cap() = 16_000_000` is the new ceiling for these registrations. Other ops still run at every bucket; tighten the cap later if we find more offenders, or loosen it once an implementation gets fast enough to make 160M tractable. Also keeps the `tee`-based `bench-csv` target from the merge so CSV output streams to the terminal as well as the file. --- Makefile | 2 +- unicode_bench/_bench.pony | 13 +++++++++++++ unicode_bench/bench_normalize.pony | 7 ++++++- unicode_bench/bench_text.pony | 17 +++++++++++------ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index a4fc226..5f59885 100644 --- a/Makefile +++ b/Makefile @@ -213,5 +213,5 @@ bench-smoke: $(bench_binary) $(bench_binary) --ponynoyield --smoke bench-csv: $(bench_binary) | $(BUILD_DIR) - $(bench_binary) --ponynoyield -csv > $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv + $(bench_binary) --ponynoyield -csv | tee $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv @echo "wrote $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv" diff --git a/unicode_bench/_bench.pony b/unicode_bench/_bench.pony index 32c0cc2..db2885e 100644 --- a/unicode_bench/_bench.pony +++ b/unicode_bench/_bench.pony @@ -25,6 +25,19 @@ primitive BenchSizes fun samples(): USize => 20 fun sample_ns(): U64 => 30_000_000 + fun heavy_op_cap(): USize => + """ + Maximum input size for "heavy" operations — ones whose per-byte cost + is high enough that a single iteration at 160M takes minutes + (`Text.from_string[indexed]` builds an O(n) grapheme bitmap; + `Normalize.nfc` over `combining_marks` runs the canonical reorder + over a 6-cp combining run per base letter, then composes). Gate + such registrations with `if size <= BenchSizes.heavy_op_cap()`. + Tighten this if even 16M takes too long; loosen once the + implementations are fast enough that the top bucket is tractable. + """ + 16_000_000 + fun default_cfg(): BenchConfig => BenchConfig(where samples' = samples(), max_sample_time' = sample_ns()) diff --git a/unicode_bench/bench_normalize.pony b/unicode_bench/bench_normalize.pony index 2896094..0f9a30c 100644 --- a/unicode_bench/bench_normalize.pony +++ b/unicode_bench/bench_normalize.pony @@ -23,7 +23,12 @@ primitive BenchNormalize _nfc(bench, "Normalize.nfc/ascii/" + lbl, ascii, cfg) _nfc(bench, "Normalize.nfc/latin-pre/" + lbl, latin_pre, cfg) _nfc(bench, "Normalize.nfc/latin-dec/" + lbl, latin_dec, cfg) - _nfc(bench, "Normalize.nfc/combining/" + lbl, combining, cfg) + // `combining` is the worst-case input — every base letter carries + // 5 marks that must be canonically reordered, then composed if + // possible. At 160M a single call takes minutes; cap at heavy_op_cap. + if size <= BenchSizes.heavy_op_cap() then + _nfc(bench, "Normalize.nfc/combining/" + lbl, combining, cfg) + end _nfkd(bench, "Normalize.nfkd/latin-pre/" + lbl, latin_pre, cfg) _nfkc(bench, "Normalize.nfkc/latin-pre/" + lbl, latin_pre, cfg) diff --git a/unicode_bench/bench_text.pony b/unicode_bench/bench_text.pony index 85c929a..b2598e1 100644 --- a/unicode_bench/bench_text.pony +++ b/unicode_bench/bench_text.pony @@ -20,12 +20,17 @@ primitive BenchText _from_string(bench, "Text.from_string/mixed/" + lbl, mixed, cfg) _from_string(bench, "Text.from_string/emoji/" + lbl, emoji, cfg) - _from_string_indexed(bench, - "Text.from_string[indexed]/ascii/" + lbl, ascii, cfg) - _from_string_indexed(bench, - "Text.from_string[indexed]/cjk/" + lbl, cjk, cfg) - _from_string_indexed(bench, - "Text.from_string[indexed]/emoji/" + lbl, emoji, cfg) + // Indexed Text construction walks the grapheme cursor over the + // entire string to build the bitmap. At the 160M bucket a single + // iteration takes minutes — skip it to keep the suite tractable. + if size <= BenchSizes.heavy_op_cap() then + _from_string_indexed(bench, + "Text.from_string[indexed]/ascii/" + lbl, ascii, cfg) + _from_string_indexed(bench, + "Text.from_string[indexed]/cjk/" + lbl, cjk, cfg) + _from_string_indexed(bench, + "Text.from_string[indexed]/emoji/" + lbl, emoji, cfg) + end fun _from_string( bench: PonyBench, From 865945fb45e0013c7f5573e86350251448d847aa Mon Sep 17 00:00:00 2001 From: Red Davies Date: Thu, 18 Jun 2026 12:39:28 -0400 Subject: [PATCH 3/3] bench: report exact byte counts in labels instead of K/M abbreviations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BenchSizes.label` previously rounded down to a K or M suffix, which lost precision: the 1_600-byte bucket showed up as "1K" (not 1 KiB); the 160_000_000-byte bucket as "152M" (not 152 MiB). Bench output now reads `Bytes.is_valid_utf8/ascii/1600B` and `Text.from_string/emoji/160000000B` — uglier but unambiguous, and the CSV column sorts numerically by suffix-stripped digits. --- unicode_bench/_bench.pony | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/unicode_bench/_bench.pony b/unicode_bench/_bench.pony index db2885e..87cb81b 100644 --- a/unicode_bench/_bench.pony +++ b/unicode_bench/_bench.pony @@ -17,10 +17,13 @@ primitive BenchSizes [as USize: 1_600] fun label(size: USize): String => - if size >= 1_048_576 then (size / 1_048_576).string() + "M" - elseif size >= 1_024 then (size / 1_024).string() + "K" - else size.string() + "B" - end + """ + Exact byte count with a `B` suffix. We don't round to K/M + abbreviations because that loses precision — the 1_600-byte + bucket isn't 1 KiB, and `Text.from_string/emoji/152M` reads as + "152 × 1 MiB" when the actual size is 160_000_000. + """ + size.string() + "B" fun samples(): USize => 20 fun sample_ns(): U64 => 30_000_000