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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

### Changed

Expand Down
30 changes: 29 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 | tee $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv
@echo "wrote $(BUILD_DIR)/bench-$(shell git rev-parse --short HEAD).csv"
214 changes: 214 additions & 0 deletions unicode_bench/_bench.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
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 =>
"""
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

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())

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)
115 changes: 115 additions & 0 deletions unicode_bench/_corpora.pony
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading