Skip to content

Remove per-line and per-word allocations from hot text paths - #13396

Merged
niksedk merged 1 commit into
mainfrom
perf/hot-text-paths-round4
Aug 9, 2026
Merged

Remove per-line and per-word allocations from hot text paths#13396
niksedk merged 1 commit into
mainfrom
perf/hot-text-paths-round4

Conversation

@niksedk

@niksedk niksedk commented Aug 9, 2026

Copy link
Copy Markdown
Member

More of what @ivandrofly has been finding in #13383 / #13301 / #12922: allocations and rescans in helpers that run once per subtitle line, per word or per character. Nothing here changes behaviour — the point is doing the same work with less garbage.

Change casing — the big one

StrippableText's name loop called name.ToLowerInvariant() on every entry of the name list, for every paragraph. For English that list is ~8000 names, so casing a 200-line subtitle allocated ~1.6 million throwaway strings. lower is already lower case, so an OrdinalIgnoreCase search finds exactly the same positions — which is what the loop's own continuation search two lines below already used.

Same method, same loop: the name-end character set concatenated a literal with Environment.NewLine per candidate match, and lower.Substring(start).StartsWith("don't") allocated the rest of the line to test five characters.

Also in this path:

  • Two sb.ToString().EndsWith(..) suffix tests inside the per-character casing loop copied the whole accumulated line each time.
  • FixCasingAfterTitles took a Substring of the tail for every character position — quadratic on long lines.
  • FixCasing ran RemoveHtmlTags twice on the same text per paragraph.

Character counting

CalcCjk.IsCjk allocated a one-character string and ran a compiled regex over it for every character outside two hard-coded ranges — and the grid re-reads CPS and line length on every repaint. It now tests the Unicode block ranges directly.

Because a mistyped range would silently shift CJK character counts, CalcCjkTest.IsCjk_MatchesRegexForEveryChar pins the rewrite to the regex it replaced across all 65536 chars.

CalcNoSpaceCpsOnly / CalcNoSpaceOrPunctuationCpsOnly allocated a fresh calculator on every call, throwing away the memoization CalcFactory goes to some trouble to provide.

Subtitle formats

  • SubStationAlpha never got the two fixes [V4+ Styles] already has: style lookup is now a HashSet probe instead of a linear scan of the style list per paragraph, and TrimBuilder replaces sb.ToString().Trim() + newline, which copied the finished output twice more.
  • MicroDVD had ten tag branches that each counted a tag over the whole line before the cheap StartsWith that rejects it. Operands swapped, so the common (untagged) case costs one prefix compare instead of ten full scans.
  • SAMI: dropped a ToUpperInvariant() copy of each cue that fed an already-OrdinalIgnoreCase search, replaced two Substring(..).ToUpperInvariant() character scans (two to four string allocations per scanned character), hoisted a ~70-char set out of a per-character loop, and built the milliseconds string in a StringBuilder that was already in scope instead of += per digit.
  • SubViewer 2.0's IsMine joined the entire file into one string to look for [br], which cannot straddle a line — and .sub is a crowded extension, so this sits on a common probe path.
  • Regex.Match(x).SuccessIsMatch(x) in 32 places. The first allocates a Match and its group machinery per line to answer a bool.

Hearing impaired / fix common errors / OCR / spell check

  • Utilities.IsAllUppercase / HasUppercase replace s == s.ToUpperInvariant() and s != s.ToLowerInvariant() at 11 sites. Both are pinned to the string comparison they replace for every character, plus Greek/ß/accented sample lines.
  • The uppercase whitelist HashSet was rebuilt from settings on every line; it is now cached against the list instance it came from, so a settings change still takes effect.
  • ReInsertHtmlTags did ContainsKey + indexer per character (auto-break runs this per line, and per keystroke with auto-break-while-typing).
  • Helper.FixDash ran RemoveHtmlTags(prev.Text).TrimEnd() twice in one condition and counted at most three lines through LINQ with a TrimStart() string per line.
  • OcrFixReplaceList2, all per OCR'd word: four ContainsKey+indexer pairs that each built their key string twice, two inline char[] allocations, and a "\\ell" + postfix concatenation plus full path scan whose answer never changes.
  • SpellCheckWordLists built both candidate phrases inside the loop over the user phrase list instead of once.

Benchmarks

BenchmarkDotNet 0.15.8, Apple M4, .NET 10.0.7, default job. The same benchmarks (tests/benchmarks/HotTextPathRound4Benchmarks.cs, added here) run against a git stashed baseline.

Benchmark Before After Ratio Alloc before Alloc after
FixCasingNormal (200 lines) 53.05 ms 26.26 ms 0.50 63.16 MB 1.64 MB
LoadSami (500 cues) 1.554 ms 1.211 ms 0.78 4.47 MB 2.04 MB
SubStationAlphaToText (500) 628.8 µs 473.2 µs 0.75 879.7 KB 617.6 KB
MicroDvdToText (500) 189.5 µs 151.4 µs 0.80 338.0 KB 338.0 KB
CalcCjk CountLatin 3.172 µs 2.184 µs 0.69 2496 B 1248 B
CalcCjk CountJapanese 1.251 µs 1.249 µs 1.00 768 B 768 B
RemoveHearingImpaired (500) 1.159 ms 1.128 ms 0.97 2.62 MB 2.61 MB
AutoBreakTagged 10.375 µs 10.251 µs 0.99 11.72 KB 11.72 KB

The bottom three are honestly within noise. Japanese text already hit the two hard-coded ranges in IsCjk, so only non-CJK text gets the win there; the RemoveHearingImpaired and auto-break changes are allocation hygiene rather than a measurable speed-up. Kept because they are strictly less work, not because the table shows anything.

Test plan

  • dotnet test tests/libse/LibSETests.csproj — 948 passed
  • dotnet test tests/libuilogic/LibUiLogicTests.csproj — 149 passed
  • New CalcCjkTestIsCjk matches the old regex for all 65536 chars
  • New UtilitiesCasingProbeTest — both casing probes match the string comparison they replace for all 65536 chars
  • Round-trip harness over 14 formats (SSA, MicroDVD, SAMI, SubViewer, SubRip, DVD Studio Pro ×3, DVD Subtitle, LRC ×3, TMPlayer, NVivo), writing + reading back + IsMine, plain and styled input: output is byte-identical before and after (same md5)
  • src/ui/UI.csproj builds
  • Tools → Change casing → Normal on a real subtitle, and Remove text for HI, to confirm in-app behaviour

Deliberately not in this PR

Avoided Utilities.StartsAndEndsWithTag, StringExtensions, AdvancedSubStationAlpha, SubRip, SubtitleFormat and MergeAndSplitHelper so this doesn't collide with the open #13381 and #13372.

The sweep also turned up a set of algorithmic problems that are too big to bundle here — happy to open them separately:

  • TimedText10.MakeParagraph re-runs a whole-document //ttml:region XPath per cue — O(n²) on every TTML save.
  • MergeAndSplitHelper.HandleFormatting formats every paragraph from the current index to the end of the file on every translate request (~n²/2 SetTagsAndReturnTrimmed calls per auto-translate run).
  • MergeLinesSameTextUtils has no early exit on the time gap, so it is O(n²) — and it runs unconditionally when opening subtitles from MP4/DASH. The UI copy of the same algorithm does break; libse never got it.
  • The "Remove text for hearing impaired" window's 500 ms preview timer has no _dirty guard (its sibling FixNetflixErrors does), so it re-detects the language and re-parses names.xml twice a second for as long as the window is open.
  • AssaStylesViewModel.UpdateUsages is O(styles × paragraphs) with two TrimStart allocations per pair.

🤖 Generated with Claude Code

Round 4 of the micro-perf hunt, in the same vein as #13383: the waste is
allocations and rescans in helpers that run once per subtitle line, per
word or per character.

Change casing (the big one)
- StrippableText's name loop lower-cased every entry of the name list on
  every paragraph. For English that list is ~8000 names, so a 200-line
  subtitle allocated 1.6 million throwaway strings. "lower" is already
  lower case, so an OrdinalIgnoreCase search finds the same positions -
  which is what the loop's own continuation search already used.
- Hoisted the name-end character set (it concatenated a literal with
  Environment.NewLine per candidate match) and replaced two
  sb.ToString().EndsWith(..) suffix tests, which copied the whole
  accumulated line per character, with an in-place compare.
- FixCasingAfterTitles took a Substring of the rest of the line for every
  character position; it now compares the tail in place.
- FixCasing ran RemoveHtmlTags twice on the same text per paragraph.

Character counting
- CalcCjk.IsCjk allocated a one-character string and ran a regex over it
  for every character outside two hard-coded ranges - and the grid re-reads
  CPS and line length on every repaint. Now tests the block ranges directly;
  CalcCjkTest pins it to the old regex for all 65536 chars.
- CalcNoSpaceCpsOnly / CalcNoSpaceOrPunctuationCpsOnly allocated a new
  calculator per call, throwing away CalcFactory's memoization.

Subtitle formats
- SubStationAlpha: ported the two fixes [V4+ Styles] already had - style
  lookup through a HashSet instead of a linear list scan per paragraph,
  and TrimBuilder instead of copying the finished output twice.
- MicroDVD: ten tag branches each counted a tag over the whole line before
  the cheap StartsWith that rejects it; operands swapped.
- SAMI: dropped an uppercased copy of each cue that fed an already
  ignore-case search, replaced two substring+uppercase character scans,
  hoisted a character set out of a per-character loop, and built the
  milliseconds string in the StringBuilder that was already in scope.
- SubViewer 2.0's IsMine joined the whole file into one string to look for
  "[br]", which cannot straddle a line.
- Regex.Match(x).Success -> IsMatch(x) in 32 places (allocates a Match plus
  its group machinery per line, for a bool).

Hearing impaired / fix common errors / OCR / spell check
- Utilities.IsAllUppercase and HasUppercase replace "s == s.ToUpperInvariant()"
  and "s != s.ToLowerInvariant()" at 11 sites; both are pinned to the string
  comparison they replace for every character.
- The uppercase whitelist set was rebuilt from settings on every line.
- ReInsertHtmlTags did two dictionary probes per character; TryGetValue now.
- Helper.FixDash ran RemoveHtmlTags twice per call and counted at most three
  lines through LINQ with a TrimStart string per line.
- OcrFixReplaceList2: four ContainsKey+indexer pairs each building their key
  twice, two inline char[] allocations and a path scan with a concatenation,
  all per OCR'd word.
- SpellCheckWordLists built both candidate phrases inside the loop over the
  user phrase list rather than once.

Verified with BenchmarkDotNet (Apple M4, .NET 10), same benchmarks run
against a stashed baseline:

| Benchmark              | Before    | After     | Ratio | Alloc before | Alloc after |
|------------------------|-----------|-----------|-------|--------------|-------------|
| FixCasingNormal (200)  | 53.05 ms  | 26.26 ms  | 0.50  | 63.16 MB     | 1.64 MB     |
| LoadSami (500)         | 1.554 ms  | 1.211 ms  | 0.78  | 4.47 MB      | 2.04 MB     |
| SubStationAlphaToText  | 628.8 us  | 473.2 us  | 0.75  | 879.7 KB     | 617.6 KB    |
| MicroDvdToText (500)   | 189.5 us  | 151.4 us  | 0.80  | 338.0 KB     | 338.0 KB    |
| CalcCjk CountLatin     | 3.172 us  | 2.184 us  | 0.69  | 2496 B       | 1248 B      |
| RemoveHearingImpaired  | 1.159 ms  | 1.128 ms  | 0.97  | 2.62 MB      | 2.61 MB     |
| AutoBreak (tagged)     | 10.375 us | 10.251 us | 0.99  | 11.72 KB     | 11.72 KB    |

The last two are within noise - those changes are allocation hygiene, not
a measurable win, and are kept because they are strictly less work.

Behaviour: 948 libse + 149 libuilogic tests pass, and a round-trip harness
over 14 formats (write, read back, IsMine; plain and styled input) produces
byte-identical output before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@niksedk
niksedk merged commit bcd9b1f into main Aug 9, 2026
1 check passed
@niksedk
niksedk deleted the perf/hot-text-paths-round4 branch August 9, 2026 11:18
@niksedk niksedk mentioned this pull request Aug 10, 2026
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.

1 participant