Repository navigation
Use whole-string invariant casing in string ToUpperOrdinal and ToLowerOrdinal - #716
Merged
Merged
Conversation
This was referenced Oct 12, 2026
This was referenced Oct 14, 2026
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.
string.ToUpperOrdinal()/string.ToLowerOrdinal()(polyfilled below net11, so on every released runtime) copied the string to achar[], cased it one char at a time throughchar.ToUpperInvariant/char.ToLowerInvariant(plus a string allocation per surrogate pair), and built a new string from the array.Ordinal casing is invariant casing with a few exceptions (upper leaves
U+017FLONG S alone; lower leaves a char alone when lowering would move it out of its ordinal upper-casing class, e.g. KELVIN SIGN), so the framework's whole-stringToUpperInvariant()/ToLowerInvariant()can do the bulk of the work:U+017Fat the positionsIndexOffinds it. Lower walks the result and applies the existing ordinal check only to non-ASCII chars that actually changed (surrogate pairs are checked as the scalar they encode), copying to achar[]only if something has to be put back. If the cased string ever had a different length, it falls back to the old path.Ascii.IsValidon net8+, a loop before that) is passed straight toToUpperInvariant()/ToLowerInvariant(), which is vectorized and needs no fix-up because none of the exceptions are ASCII. Any other string goes through the old per-char path, unchanged.Why the split: I measured the whole-string approach everywhere first. On .NET Framework it wins for every input. On net8/net10 it wins for ASCII and for non-Latin scripts, but text that is ASCII with the odd Latin-1 char got up to 2x slower (the per-char path is cheap for Latin-1 there, while the whole-string call leaves its fast path at the first non-ASCII char). So off .NET Framework only the ASCII case, which is the common one for ordinal casing, takes the new path and nothing gets slower.
Equivalence
Before relying on it I compared the new implementation with the old one (
ToOrdinalCase(char[], bool), called directly) in a throwaway harness on net48, net8.0 and net10.0, for upper and lower:U+017F/U+212A/U+0130U+10000-U+10FFFFalone and in contextZero differences on all three runtimes. (net8/net10 have 5 scalars where ordinal lower differs from invariant lower; .NET Framework 4.8 has none.)
Measurements
Release, x64, best of 3. Machine was busy, so treat small differences as noise.
éevery 11éevery 11"Content-Type"x 1M"Content-Type"x 1MThe net8/net10 non-ASCII rows run the same code before and after.
Tests
Added to
PolyfillTests_OrdinalCasing.cs:String_OrdinalCasing_MatchesCharCasingForEveryBmpChar: every BMP char, alone and between ASCII letters, must case exactly aschar.ToUpperOrdinal/char.ToLowerOrdinaldo (lone surrogates unchanged), plus one string holding every non-surrogate BMP char. This runs the equivalence check on every target framework in CI, including the ones not installed locally.String_OrdinalCasing_MatchesSpanCasingForSupplementaryPlanes: every scalar of plane 1 and a sample of the other planes, compared to the span overloads (which still use the per-char implementation)String_ToUpperOrdinal_LongS:U+017Fat the start, end, repeated, and between surrogate pairsString_ToLowerOrdinal_PreservesOrdinalClass: KELVIN SIGN, OHM SIGN, ANGSTROM SIGN, capital sharp S and the capital theta symbol, alone and mixed with text and surrogate pairsString_OrdinalCasing_Ascii: all 128 ASCII chars repeated, and ASCII text with a single non-ASCII char at the start, middle and endString_OrdinalCasing_Surrogates(adjacent pairs, lone halves, a high surrogate before a pair),String_OrdinalCasing_Unchanged