Remove per-line and per-word allocations from hot text paths - #13396
Merged
Conversation
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 calledname.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.loweris already lower case, so anOrdinalIgnoreCasesearch 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.NewLineper candidate match, andlower.Substring(start).StartsWith("don't")allocated the rest of the line to test five characters.Also in this path:
sb.ToString().EndsWith(..)suffix tests inside the per-character casing loop copied the whole accumulated line each time.FixCasingAfterTitlestook aSubstringof the tail for every character position — quadratic on long lines.FixCasingranRemoveHtmlTagstwice on the same text per paragraph.Character counting
CalcCjk.IsCjkallocated 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_MatchesRegexForEveryCharpins the rewrite to the regex it replaced across all 65536 chars.CalcNoSpaceCpsOnly/CalcNoSpaceOrPunctuationCpsOnlyallocated a fresh calculator on every call, throwing away the memoizationCalcFactorygoes to some trouble to provide.Subtitle formats
[V4+ Styles]already has: style lookup is now aHashSetprobe instead of a linear scan of the style list per paragraph, andTrimBuilderreplacessb.ToString().Trim() + newline, which copied the finished output twice more.StartsWiththat rejects it. Operands swapped, so the common (untagged) case costs one prefix compare instead of ten full scans.ToUpperInvariant()copy of each cue that fed an already-OrdinalIgnoreCasesearch, replaced twoSubstring(..).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 aStringBuilderthat was already in scope instead of+=per digit.IsMinejoined the entire file into one string to look for[br], which cannot straddle a line — and.subis a crowded extension, so this sits on a common probe path.Regex.Match(x).Success→IsMatch(x)in 32 places. The first allocates aMatchand its group machinery per line to answer a bool.Hearing impaired / fix common errors / OCR / spell check
Utilities.IsAllUppercase/HasUppercasereplaces == s.ToUpperInvariant()ands != s.ToLowerInvariant()at 11 sites. Both are pinned to the string comparison they replace for every character, plus Greek/ß/accented sample lines.HashSetwas rebuilt from settings on every line; it is now cached against the list instance it came from, so a settings change still takes effect.ReInsertHtmlTagsdidContainsKey+ indexer per character (auto-break runs this per line, and per keystroke with auto-break-while-typing).Helper.FixDashranRemoveHtmlTags(prev.Text).TrimEnd()twice in one condition and counted at most three lines through LINQ with aTrimStart()string per line.OcrFixReplaceList2, all per OCR'd word: fourContainsKey+indexer pairs that each built their key string twice, two inlinechar[]allocations, and a"\\ell" + postfixconcatenation plus full path scan whose answer never changes.SpellCheckWordListsbuilt 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 agit stashed baseline.FixCasingNormal(200 lines)LoadSami(500 cues)SubStationAlphaToText(500)MicroDvdToText(500)CalcCjkCountLatinCalcCjkCountJapaneseRemoveHearingImpaired(500)AutoBreakTaggedThe 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; theRemoveHearingImpairedand 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 passeddotnet test tests/libuilogic/LibUiLogicTests.csproj— 149 passedCalcCjkTest—IsCjkmatches the old regex for all 65536 charsUtilitiesCasingProbeTest— both casing probes match the string comparison they replace for all 65536 charsIsMine, plain and styled input: output is byte-identical before and after (same md5)src/ui/UI.csprojbuildsDeliberately not in this PR
Avoided
Utilities.StartsAndEndsWithTag,StringExtensions,AdvancedSubStationAlpha,SubRip,SubtitleFormatandMergeAndSplitHelperso 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.MakeParagraphre-runs a whole-document//ttml:regionXPath per cue — O(n²) on every TTML save.MergeAndSplitHelper.HandleFormattingformats every paragraph from the current index to the end of the file on every translate request (~n²/2SetTagsAndReturnTrimmedcalls per auto-translate run).MergeLinesSameTextUtilshas 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 doesbreak; libse never got it._dirtyguard (its siblingFixNetflixErrorsdoes), so it re-detects the language and re-parsesnames.xmltwice a second for as long as the window is open.AssaStylesViewModel.UpdateUsagesis O(styles × paragraphs) with twoTrimStartallocations per pair.🤖 Generated with Claude Code