Fix performance regressions in core hot paths (enum dehumanize, clock notation, metric numerals, transformers, truncators, string operations)#1633
Conversation
…umanize, and SentenceCase Co-authored-by: clairernovotny <1427284+clairernovotny@users.noreply.github.com>
… checks Co-authored-by: clairernovotny <1427284+clairernovotny@users.noreply.github.com>
…y string literal Co-authored-by: clairernovotny <1427284+clairernovotny@users.noreply.github.com>
There was a problem hiding this comment.
Pull Request Overview
This PR contains performance and code quality improvements across multiple areas of the Humanizer library, focusing on refactoring to improve efficiency and maintainability without changing functionality.
- Optimized string manipulation and dictionary lookups by using more efficient collection types and string construction patterns
- Refactored switch expressions to replace Dictionary-based lookups for better performance
- Reduced unnecessary type conversions and simplified conditional logic
Reviewed Changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| ToSentenceCase.cs | Optimized character casing logic by reusing variables and using culture-specific char.ToUpper instead of TextInfo.ToUpper |
| StringDehumanizeExtensions.cs | Improved efficiency with StringSplitOptions.RemoveEmptyEntries and early returns |
| MetricNumeralExtensions.cs | Refactored to use empty string literals, flattened nested conditions, and improved lookup logic using UnitPrefixes dictionary |
| EsTimeOnlyToClockNotationConverter.cs | Replaced Dictionary with switch expression for cleaner, more efficient code |
| CaTimeOnlyToClockNotationConverter.cs | Replaced Dictionary with switch expression for cleaner, more efficient code |
| EnumCache.cs | Changed internal Dictionary to FrozenDictionary for better read performance |
|
@copilot Continue looking for more optimizations. Find and fix the next 10 most impactful ones |
…r consistency Co-authored-by: clairernovotny <1427284+clairernovotny@users.noreply.github.com>
Co-authored-by: clairernovotny <1427284+clairernovotny@users.noreply.github.com>
Completed in commit 40e8257. Implemented 10 high-impact optimizations:
These target hot paths with unnecessary allocations, redundant string operations, and enable early exits. All changes passed security scan with 0 alerts. |
| + fractionalPart.ToString("d" + decimalPlaces) | ||
| + (extraZeroes <= 0 ? "" : new string('0', extraZeroes)) | ||
| + (formats.HasValue && formats.Value.HasFlag(MetricNumeralFormats.WithSpace) ? " " : string.Empty) | ||
| + (extraZeroes <= 0 ? string.Empty : new string('0', extraZeroes)) |
There was a problem hiding this comment.
[nitpick] The ternary operator conditionally creates a new string when extraZeroes > 0. This allocation could be avoided by using an empty string literal \"\" instead of string.Empty, or by conditionally appending the entire expression. However, the more idiomatic approach for empty string is already used, so this is acceptable.
| foreach (var c in value) | ||
| { | ||
| if (!char.IsUpper(c)) | ||
| { | ||
| isAllUpper = false; | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
This foreach loop looks as if it might be testing whether every sequence element satisfies a predicate - consider using '.All(...)'.
| foreach (var c in value) | ||
| { | ||
| if (char.IsLetterOrDigit(c)) | ||
| { | ||
| alphaNumericalCount++; | ||
| if (alphaNumericalCount > length) | ||
| { | ||
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
|
@copilot review the feedback and fix. perf matters the most. |
| foreach (var c in value) | ||
| { | ||
| if (!char.IsUpper(c)) | ||
| { | ||
| isAllUpper = false; | ||
| break; | ||
| } | ||
| } |
Check notice
Code scanning / CodeQL
Missed opportunity to use All Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 months ago
To fix this issue, replace the manual loop that checks if every character in value is uppercase with a call to LINQ's .All(char.IsUpper) method. This involves replacing the local variable initialization, the loop, and the break logic with a simple assignment: var isAllUpper = value.All(char.IsUpper);. You will need to ensure that System.Linq is imported for access to .All. The change should occur in the FromPascalCase method in StringHumanizeExtensions.cs, specifically over lines 50-58. Also, check import statements at the top of the file and add using System.Linq; if not present; only do so if you're permitted by the context.
| @@ -1,4 +1,5 @@ | ||
| using System.Runtime.InteropServices; | ||
| using System.Linq; | ||
|
|
||
| namespace Humanizer; | ||
|
|
||
| @@ -47,15 +48,7 @@ | ||
| .Select(match => | ||
| { | ||
| var value = match.Value; | ||
| var isAllUpper = true; | ||
| foreach (var c in value) | ||
| { | ||
| if (!char.IsUpper(c)) | ||
| { | ||
| isAllUpper = false; | ||
| break; | ||
| } | ||
| } | ||
| var isAllUpper = value.All(char.IsUpper); | ||
| return isAllUpper && | ||
| (value.Length > 1 || (match.Index > 0 && input[match.Index - 1] == ' ') || value == "I") | ||
| ? value |
| foreach (var c in value) | ||
| { | ||
| if (char.IsLetterOrDigit(c)) | ||
| { | ||
| alphaNumericalCount++; | ||
| if (alphaNumericalCount > length) | ||
| { | ||
| break; | ||
| } | ||
| } | ||
| } |
Check notice
Code scanning / CodeQL
Missed opportunity to use Where Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 months ago
The best way to fix this issue is to replace the foreach loop on line 30 that iterates through each character in value but only counts those that are letters or digits, with an explicit use of .Where(char.IsLetterOrDigit) from System.Linq. In particular, we want to count up to length + 1 alpha-numerical characters to determine if truncation is needed. Instead of a manual loop, we can use value.Where(char.IsLetterOrDigit).Take(length + 1).Count() to compute the count. This reduces the amount of code, increases readability, and directly expresses the filtering intent.
The required changes are:
- Add
using System.Linq;at the top if it is not already present (the snippet does not show us the top of the file, so this is needed just in case). - Replace lines 29–40 (the counting loop and variable) with a single line that uses LINQ to get the count.
No method definitions or variable definitions outside this scope are needed.
| @@ -1,4 +1,5 @@ | ||
| namespace Humanizer; | ||
| using System.Linq; | ||
|
|
||
| /// <summary> | ||
| /// Truncate a string to a fixed number of letters or digits | ||
| @@ -26,18 +27,7 @@ | ||
| } | ||
|
|
||
| // Count letter or digit characters up to length + 1 to determine if truncation is needed | ||
| var alphaNumericalCount = 0; | ||
| foreach (var c in value) | ||
| { | ||
| if (char.IsLetterOrDigit(c)) | ||
| { | ||
| alphaNumericalCount++; | ||
| if (alphaNumericalCount > length) | ||
| { | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| var alphaNumericalCount = value.Where(char.IsLetterOrDigit).Take(length + 1).Count(); | ||
|
|
||
| if (alphaNumericalCount <= length) | ||
| { |
Reviewed the feedback. The bot's suggestions (comments 2470765743 and 2470765756) recommend using LINQ methods (.All() and .Where()), but these would hurt performance: Comment 2470765743 (StringHumanizeExtensions): Suggests .All() instead of foreach
Comment 2470765756 (FixedNumberOfCharactersTruncator): Suggests .Where() instead of foreach
Comment 2470765732 (MetricNumeralExtensions): Nitpick about string.Empty - already using it per conventions All three optimizations are intentional for performance and align with "perf matters the most". No changes needed. |
Benchmarks showed 7-59% slowdowns in frequently-called methods between 3.0.0-rc.6 and main. Root causes: non-frozen dictionaries in caches, repeated allocations in hot paths, and per-call collection instantiation.
Enum Dehumanization (59% slowdown)
EnumCache.cs: Changed
DehumanizedfromDictionary<string, T>toFrozenDictionary<string, T>. The humanized cache was already frozen but dehumanized was not, causing slower lookups.Clock Notation Converters (36% slowdown)
EsTimeOnlyToClockNotationConverter.cs, CaTimeOnlyToClockNotationConverter.cs: Replaced per-call
Dictionary<int, string>instantiation with switch expressions:Metric Numerals (7-16% slowdown)
MetricNumeralExtensions.cs:
GetUnitText: Usesymbol.ToString()and eliminate redundantHasValuechecksBuildMetricNumber: SingleIndexOfpass instead ofContains+IndexOfIsInvalidMetricNumeral:UnitPrefixes.ContainsKeyinstead of twoList.Containscallsstring.Emptyfor consistency with .NET conventionsString Transformers (22% slowdown)
ToSentenceCase.cs: Use
culture.TextInfo.ToUpper(char)for consistency and proper null handlingStringDehumanizeExtensions.cs: Remove unnecessary
Replace(" ", "")after concatenating already-pascalized wordsAdditional Optimizations (10 high-impact changes)
Truncation Operations
FixedNumberOfCharactersTruncator.cs: Replace
Count(char.IsLetterOrDigit)(full iteration) with early-exit loop - eliminates unnecessary iteration when truncation not neededFixedNumberOfWordsTruncator.cs: Replace
Split().Lengthwith character-by-character counting - eliminates array allocation just for countString Operations
StringHumanizeExtensions.cs: Multiple optimizations:
IndexOfAny(['_', '-'])instead of twoContainscalls (single search vs two)Replace(" ", "")allocationvalue.All(char.IsUpper)LINQ with foreach for early exitToTitleCase.cs: Optimize
Overwritemethod - direct StringBuilder character assignment instead ofRemove().Insert()patternByteSize.cs: Combine
!Contains('#') && !Contains('0')into singleIndexOfAny(['#', '0']) < 0Validation
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.