Skip to content

Unicode Interval Search Refinement (2026-09)

Unicode Interval Search Refinement

I replaced PR #177's partition_point lookup with a binary search that tests complete intervals and returns immediately on an inclusive hit. This removes the substantial regressions found in the earlier expanded evaluation for the measured workloads, while improving multilingual word extraction further.

The independent confirmation reduces multilingual word-extraction time by 14.6%, mixed Unicode identifier extraction by 11–12%, and letter-class searches without a match by 15.8–18.9%. Negated-letter extraction improves by 16.6–17.0%, combining-mark extraction by approximately 6.8–6.9%, and Greek extraction by 7.5–9.4%. Both repetitions show the same direction with disjoint individual mean intervals for these cases. The earlier regressions in letter-class misses, negated letters, combining marks, and Greek extraction have become gains; small costs in other cases are still reported below.

These are whole-workload times against main after #178, not timings of the helper alone. They characterize the supplied fixtures on one M1 Ultra; they neither measure application frequencies nor establish a cross-platform gain.

Bounded alternatives

The diagnostic compared main, the original partition search, a manual lower-bound loop over pairs, and direct interval membership. Each of six cases ran all four variants in a rotating order and then the reverse order: 48 runs, 1,440 samples. All four executables passed the 121 benchmark smoke cases, including expected counts and full raw capture-trace comparison against pinned Oniguruma before timing.

Simply retaining paired indexing with the old lower-bound loop did not help. It regressed all six diagnostic cases. Direct membership improved all six, so I selected it for a separate, fresh build and full-matrix confirmation. I did not tune table-size thresholds, specialize individual scripts, or change inputs between variants.

The following diagnostic percentages are relative to main. Negative means less time. These are selection data; the independent confirmation below is the evidence for the final implementation.

CaseOriginal partition searchPaired lower-bound loopDirect interval membership
unicode_words-4.5%+5.3%-15.9%
letters_latin_long+3.2%+5.1%-8.5%
letters_no_match_long+13.4%+12.8%-18.7%
greek_long+7.7%+4.7%-8.6%
combining_marks_long+5.6%+5.3%-5.9%
negated_letters_long+5.3%+7.5%-16.3%

The diagnostic artifact retains every attempted variant, both patches, binary hashes, all samples, the exact runner, and disassembly of all four range-lookup helpers.

Why the implementation differs

Both C's original loop and partition_point find the first interval whose upper bound is not below the code point, then test its lower bound. The new loop compares the midpoint's complete interval. A code point above it selects the right subslice; one below it selects the left subslice; an inclusive hit returns immediately. Excluding the midpoint strictly shrinks the remaining slice on every miss, retaining logarithmic worst-case search and constant auxiliary space.

The input is still the existing counted array of sorted, disjoint inclusive ranges. The length checks, first/last endpoint checks, one-range shortcut, and up-to-four-range scan remain unchanged. There is no extra allocation, compiled metadata, generated table, dependency, unsafe code, or character-class dispatch rule. The midpoint of a nonempty slice is below its length, so the right slice always has a first interval; the unwrap follows that local invariant.

In the inspected ARM64 build, the larger-table loop contains no bounds-panic calls. It can stop at an interval hit and uses data-dependent branches. The original flat-array loop and partition_point both use conditional selects, so the earlier losses cannot simply be attributed to removing branches from the C-style loop. These code-generation differences help explain the design; the measurements, not assembly alone, establish the observed performance.

Independent confirmation

I rebuilt committed candidate 4317cc2e4a37483fc5a5ba1b27464d231db0700a and compared it with the same clean main reference used previously: c6a47b9c49c62a7a4115b8da3b5fa1dd277d8ff2, which is main 486126c6861a115619ce569cf3bdc333a4ebf0aa plus the benchmark-only additions from 5450952. Both initial source states were clean. The benchmark code and inputs are byte-identical; only the range lookup differs in production code.

The full matrix has 41 cases and 164 original timed runs. Each case runs baseline/candidate/candidate/baseline or the reverse, alternating by case index after a seeded shuffle (17720260926). Every process uses 30 samples, a 500 ms warmup, and four seconds of measurement. All original measurements are retained.

A pre-push hook briefly ran cargo fmt -- --check while the second original baseline measurement of letters_ascii_long was in progress, around 16:43:20 UTC. The hook performs formatting validation only. I repeated that entire control in a fresh ABBA sequence after the matrix completed and used the clean repetition in the table. The original four runs remain in the artifact, alongside all four recovery runs; this repetition was prompted by known interference, not by the measured result. No other timing was replaced.

That gives 168 confirmation runs and 5,040 samples, plus the separate 48-run diagnostic. All 216 reported mean estimates were independently recomputed from retained sample times and iteration counts.

All values below are microseconds for the whole workload. Each before/after value averages two process means. The last column shows both paired changes; individual 95% intervals remain in the raw data. There is no pooled significance test or traffic-weighted overall score.

Unicode workloads

CaseBefore (µs)After (µs)Time changePair 1 / Pair 2
combining_marks_long35.02632.641-6.8%-6.8% / -6.8%
combining_marks_short0.6730.627-6.9%-7.2% / -6.6%
cyrillic_long30.72529.927-2.6%-2.2% / -3.0%
cyrillic_short0.5940.581-2.2%-2.4% / -2.1%
decimal_digits_long25.77825.504-1.1%-2.1% / -0.1%
decimal_digits_short0.5460.521-4.6%-4.1% / -5.1%
greek_long38.45234.839-9.4%-9.4% / -9.4%
greek_no_match_long34.56032.797-5.1%-5.4% / -4.8%
greek_no_match_short0.6360.608-4.5%-4.6% / -4.3%
greek_short0.7050.652-7.5%-7.3% / -7.8%
han_long34.35833.659-2.0%-1.6% / -2.5%
han_short0.6920.669-3.5%-2.1% / -4.7%
identifiers_mixed_long65.75758.227-11.5%-11.9% / -11.0%
identifiers_mixed_short1.2161.071-12.0%-12.8% / -11.2%
letters_ascii_long40.35240.182-0.4%-0.9% / +0.0%
letters_ascii_short0.8180.788-3.6%-3.2% / -4.0%
letters_latin_long42.89739.335-8.3%-9.2% / -7.4%
letters_latin_short0.8500.773-9.0%-9.1% / -8.9%
letters_mixed_long71.88262.797-12.6%-12.9% / -12.4%
letters_mixed_short1.2741.116-12.4%-13.0% / -11.8%
letters_no_match_long29.21523.690-18.9%-18.7% / -19.1%
letters_no_match_short0.5580.470-15.8%-15.8% / -15.8%
negated_letters_long71.15859.351-16.6%-16.2% / -17.0%
negated_letters_short1.2801.063-17.0%-17.2% / -16.7%
unicode_spaces_long39.50240.114+1.5%+1.1% / +2.0%
unicode_spaces_short0.7620.749-1.7%-2.8% / -0.6%
words_mixed_long74.05273.766-0.4%-0.5% / -0.2%
words_mixed_short1.3531.327-1.9%-3.5% / -0.2%

Existing workloads and controls

CaseBefore (µs)After (µs)Time changePair 1 / Pair 2
compilation/rust/literal0.4940.489-1.0%-0.0% / -2.0%
general_regex/rust/access_log_captures30.94131.165+0.7%+0.4% / +1.0%
general_regex/rust/email_redaction26.02325.910-0.4%-1.1% / +0.3%
general_regex/rust/email_validation6.7796.661-1.7%-1.4% / -2.1%
general_regex/rust/number_validation8.2678.116-1.8%-1.7% / -2.0%
general_regex/rust/unicode_words63.65354.382-14.6%-15.4% / -13.8%
general_regex/rust/url_extraction10.27110.181-0.9%-0.9% / -0.9%
general_regex/rust/uuid_validation5.8405.802-0.6%-0.9% / -0.4%
scanner_documents/css_117_document_19_lines_rust91.31091.829+0.6%+1.6% / -0.5%
scanner_documents/rust_81_document_31_lines_rust106.395106.006-0.4%+0.1% / -0.9%
scanner_documents/ts_279_document_28_lines_rust1032.1231021.486-1.0%-1.7% / -0.4%
single_pattern/rust/literal_exact0.1000.101+0.2%+0.1% / +0.2%
single_pattern/rust/unicode_greek0.1020.093-8.9%-9.5% / -8.2%

The largest remaining mean increase is the long Unicode-whitespace workload at +1.5%; access-log captures increase by +0.7%. Both are slower in both paired measurements with disjoint individual mean intervals, so I retain them as costs. CSS document tokenization averages +0.6%, but its pairs disagree (+1.6% and -0.5%). Literal search is approximately flat (+0.2%). TypeScript and Rust document tokenization average -1.0% and -0.4%; the TypeScript result is smaller and less consistently separated than its earlier measurement.

The large Unicode gains repeat in both orders. Small movements in unrelated controls can include code-placement effects and desktop drift; I do not attribute every change to the lookup helper or average these unlike workloads into a universal speedup. The remaining measured costs are substantially smaller than the original partition-search regressions.

Environment, validation, and reproduction

The host is an Apple M1 Ultra, 64 GiB, macOS 27.0 (26A428). Both binaries use Rust 1.96.0 / LLVM 22.1.2, Criterion 0.8.2, portable code generation, thin LTO, and --features ffi; the opt-in match cache is compiled out. No compiler or profile overrides are set. The pinned C source is f95747b462de672b6f8dbdeb478245ddf061ca53.

An exclusive advisory CPU lock excludes cooperating task builds, tests, and benchmarks, with the formatting-hook exception recorded above. Desktop background activity and CPU placement remain uncontrolled. These are warm measurements; there is no cold-start, memory, or cross-platform performance claim.

All confirmation and recovery measurements include raw samples and intervals, complete synthetic inputs, expected counts, source and binary hashes, commands, UTC order, and both exact runners. The original expanded results and the initial evaluation remain available unchanged apart from links to this refinement.

Build the baseline and candidate in separate checkouts with identical benchmark files and save the binaries before changing revisions:

./scripts/prepare-oniguruma-sources.sh
cargo bench --locked --features ffi --bench battle_bench --no-run
./baseline-bench '^unicode_classes/rust/letters_no_match_long$' \
  --bench --noplot --save-baseline baseline-1
./candidate-bench '^unicode_classes/rust/letters_no_match_long$' \
  --bench --noplot --save-baseline candidate-1
# Repeat in candidate/baseline order with distinct candidate-2/baseline-2 names.

The refined code passes the full default and all-feature suites, including the exhaustive sweep of more than 4.45 million membership results, synthetic tables with zero through 64 intervals, malformed lengths/counts, trailing words, and u32 boundaries. The cache/plain/C differential suite, strict all-target Clippy, and Rust formatting pass. Both final measured binaries pass all 121 non-cache benchmark smoke cases. The source change and its termination/safety argument are recorded in ADR-008.