Skip to content

Substring optimizations: eliminate hot-path allocations (audit top 8) - #3963

Draft
christophwille wants to merge 8 commits into
masterfrom
substring-optimizations
Draft

Substring optimizations: eliminate hot-path allocations (audit top 8)#3963
christophwille wants to merge 8 commits into
masterfrom
substring-optimizations

Conversation

@christophwille

Copy link
Copy Markdown
Member

Implements the first eight recommendations of a repository-wide Substring usage 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:

  1. AssignVariableNames.SplitName - the digit tail is already validated by the preceding loop, so the Substring + int.TryParse pair is replaced with an inline, overflow-guarded digit accumulation. Runs per variable and per reserved-name registration of every decompiled method.
  2. 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-char ToLowerInvariant. This was the largest allocation source in interactive search (the ~ branch allocated two full ToLower() strings per entity). New IsMatchTests pin the +/-/=/~ semantics.
  3. ReflectionHelper / TopLevelTypeName - the arity after the backtick is parsed in place (netstandard2.0 has no span int.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.
  4. MethodBodyDisassembler.WriteOpCode - ldarg.N/ldloc.N/stloc.N shortcut forms render their digit and param_/loc_ reference keys from static tables instead of two allocations per instruction.
  5. TextWriterTokenWriter.EscapeIdentifier - returns the original string instance when nothing needs escaping (the overwhelmingly common case; previously it always built a StringBuilder plus a fresh result string per identifier token) and appends surrogate pairs as two chars instead of a 2-char substring.
  6. CleanUpVariableName chain - all prefix/suffix cuts on the per-variable naming path are now slices; ContainsNonPrintableIdentifierChar and IsValidName gained span overloads, and only the final lowered name is materialized (single allocation). IsKeyword deliberately keeps its string parameter: it is called on that final string anyway, and the keyword HashSet has no span lookup on netstandard2.0.
  7. IL-with-C# view - ISmartTextOutput gained Write(ReadOnlySpan<char>) as a default interface method (net10.0), overridden in AvaloniaEditTextOutput to 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 on ISmartTextOutput rather than ITextOutput because the decompiler library targets netstandard2.0 (no DIMs), where a new interface member would break external implementers.
  8. MetadataMethod - the explicit-interface-implementation op_ 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):

  • Fuzzy search matching (~) now lowercases with the invariant culture instead of the current culture.
  • Reflection-name arity parsing accepts only plain ASCII digits; suffixes like Foo`+1 that int.TryParse tolerated are now rejected (not legal reflection names).

Verification: full ILSpy.sln test suite (including the complete PrettyTestRunner golden 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

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
@christophwille

Copy link
Copy Markdown
Member Author

Original analysis document: SubString.html

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