Substring optimizations: eliminate hot-path allocations (audit top 8) - #3963
Draft
christophwille wants to merge 8 commits into
Draft
Substring optimizations: eliminate hot-path allocations (audit top 8)#3963christophwille wants to merge 8 commits into
christophwille wants to merge 8 commits into
Conversation
The loop preceding the parse has already proven that the tail consists solely of ASCII digits, so the Substring+int.TryParse pair only re-validated them at the cost of a throwaway string allocation. SplitName runs per variable and per reserved-name registration for every decompiled method, making this one of the hottest Substring call sites in the decompiler. Accumulating the digits inline keeps the TryParse overflow semantics (fall back to number=1 and the unchanged name) without allocating. Assisted-by: Claude:claude-fable-5:Claude Code
AbstractSearchStrategy.IsMatch runs for every metadata row of every loaded module on each keystroke, yet it re-stripped the operator prefix per name and, for fuzzy terms, additionally lowercased both the name and the term - the largest allocation source in interactive search. The terms are invariant for the lifetime of a strategy (each keystroke builds a new request and strategy), so the stripping and lowercasing now happen once in the constructor, and the noncontiguous matcher compares spans with per-char ToLowerInvariant. This switches fuzzy matching from culture-sensitive to invariant lowercasing, which is the appropriate semantic for matching metadata names. The new IsMatchTests pin the +, -, =, ~ operator semantics (including the pre-existing quirk that =Name compares against the backtick- suffixed name of generic types) as observed before the change. Assisted-by: Claude:claude-fable-5:Claude Code
SplitTypeParameterCountFromReflectionName allocated the digits after the backtick only to feed int.TryParse and discard them, and the TopLevelTypeName constructor cut the name part twice for generic types (once at the dot, again at the backtick), dropping the first cut. Both run for every generic reflection name parsed, e.g. for typeof-valued attribute arguments and string-switch metadata. The arity is now parsed in place with a digit loop (netstandard2.0 has no span int.TryParse) and each final string is cut exactly once. The digit loop only accepts plain ASCII digits, so suffixes like `+1 that int.TryParse tolerated are now rejected; such names are not legal reflection names. New unit tests pin the parse edge cases. Assisted-by: Claude:claude-fable-5:Claude Code
WriteOpCode allocated two strings per rendered ldarg.N/ldloc.N/stloc.N instruction (the digit cut off the mnemonic plus the concatenated local-reference key), even though the shortcut forms only ever produce the indices 0-3. The digit text and the param_/loc_ reference keys now come from static tables indexed by opcode arithmetic. Assisted-by: Claude:claude-fable-5:Claude Code
EscapeIdentifier runs for every identifier token emitted, yet it built a StringBuilder plus a fresh result string even for the overwhelmingly common case of an identifier with no escapable characters. A pre-scan now returns the original instance untouched in that case, and the surrogate-pair copy appends the two chars directly instead of cutting a two-char substring. New unit tests pin the escaping behavior and the identity fast path. Assisted-by: Claude:claude-fable-5:Claude Code
CleanUpVariableName sits on the per-variable naming path of every decompiled method and allocated up to three intermediates (backtick cut, m_/_ prefix strip, lowercase-first concat) before producing its result, and its callers added further throwaway substrings when stripping get_/set_/Get/Set and interface-I prefixes. The cuts are now slices over the original name: ContainsNonPrintableIdentifierChar and IsValidName gained span overloads, and only the final lowered name is materialized, in a single allocation via char[] (netstandard2.0 has no string(span) constructor). IsKeyword keeps its string parameter - it is called on that final string anyway, and the keyword HashSet has no span lookup on netstandard2.0. Assisted-by: Claude:claude-fable-5:Claude Code
The "IL with C#" view cut up to four substrings (prefix, trimmed prefix, highlighted range, suffix) out of every source line emitted alongside an IL instruction. ISmartTextOutput now accepts a ReadOnlySpan<char> - as a default interface method falling back to Write(text.ToString()) so existing implementers keep working - and AvaloniaEditTextOutput appends the span straight into its StringBuilder. The overload lives on ISmartTextOutput rather than ITextOutput because the latter is netstandard2.0 (no default interface methods there), where a new member would break every external implementer of the decompiler library; the highlighted-comment path is typed against ISmartTextOutput already. Assisted-by: Claude:claude-fable-5:Claude Code
The MetadataMethod constructor cut the post-dot short name for every static non-generic method whose name contains a dot, only to test it for an op_ prefix that almost never matches. The prefix is now checked on a span slice first, so the substring (still required by OperatorDeclaration.GetOperatorType) is allocated only for actual explicit-interface operator implementations. Assisted-by: Claude:claude-fable-5:Claude Code
Member
Author
|
Original analysis document: SubString.html |
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.
Implements the first eight recommendations of a repository-wide
Substringusage audit (105 production call sites classified by call frequency and by what the substring result feeds into, plus pattern micro-benchmarks). Each commit corresponds to one recommendation, ranked by (frequency x allocations saved) / effort:AssignVariableNames.SplitName- the digit tail is already validated by the preceding loop, so theSubstring+int.TryParsepair is replaced with an inline, overflow-guarded digit accumulation. Runs per variable and per reserved-name registration of every decompiled method.AbstractSearchStrategy- operator-prefix stripping and (for~fuzzy terms) lowercasing now happen once per search run in the constructor instead of per candidate name per keystroke; the noncontiguous matcher compares spans with per-charToLowerInvariant. This was the largest allocation source in interactive search (the~branch allocated two fullToLower()strings per entity). NewIsMatchTestspin the+/-/=/~semantics.ReflectionHelper/TopLevelTypeName- the arity after the backtick is parsed in place (netstandard2.0 has no spanint.TryParse) and namespace/name are each cut exactly once, removing the throwaway intermediate per generic reflection name parsed. Micro-benchmark of this pattern: 74.2 -> 24.1 ns/op, 386 -> 0 B/op.MethodBodyDisassembler.WriteOpCode-ldarg.N/ldloc.N/stloc.Nshortcut forms render their digit andparam_/loc_reference keys from static tables instead of two allocations per instruction.TextWriterTokenWriter.EscapeIdentifier- returns the original string instance when nothing needs escaping (the overwhelmingly common case; previously it always built aStringBuilderplus a fresh result string per identifier token) and appends surrogate pairs as two chars instead of a 2-char substring.CleanUpVariableNamechain - all prefix/suffix cuts on the per-variable naming path are now slices;ContainsNonPrintableIdentifierCharandIsValidNamegained span overloads, and only the final lowered name is materialized (single allocation).IsKeyworddeliberately keeps its string parameter: it is called on that final string anyway, and the keywordHashSethas no span lookup on netstandard2.0.ISmartTextOutputgainedWrite(ReadOnlySpan<char>)as a default interface method (net10.0), overridden inAvaloniaEditTextOutputto append straight into its buffer; the highlighted-comment writer slices each source line instead of cutting up to four substrings per emitted line. The overload is onISmartTextOutputrather thanITextOutputbecause the decompiler library targets netstandard2.0 (no DIMs), where a new interface member would break external implementers.MetadataMethod- the explicit-interface-implementationop_check tests a span slice first and only allocates the short name for actual operators, instead of for every static non-generic method containing a dot.Deliberate behavior changes (both called out in the respective commit messages):
~) now lowercases with the invariant culture instead of the current culture.Foo`+1thatint.TryParsetolerated are now rejected (not legal reflection names).Verification: full
ILSpy.slntest suite (including the completePrettyTestRunnergolden suite, the disassembler golden tests, and the headless-Avalonia UI tests); new unit tests for the search matcher, reflection-name/arity parsing, and identifier escaping.🤖 Generated with Claude Code