Skip to content

Use whole-string invariant casing in string ToUpperOrdinal and ToLowerOrdinal - #716

Merged
SimonCropp merged 1 commit into
mainfrom
perf/string-ordinal-casing
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/string-ordinal-casing

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

string.ToUpperOrdinal() / string.ToLowerOrdinal() (polyfilled below net11, so on every released runtime) copied the string to a char[], cased it one char at a time through char.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+017F LONG 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-string ToUpperInvariant() / ToLowerInvariant() can do the bulk of the work:

  • .NET Framework: case the whole string in one call, then fix up the exceptions. Upper restores U+017F at the positions IndexOf finds 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 a char[] only if something has to be put back. If the cased string ever had a different length, it falls back to the old path.
  • Everything else: an all-ASCII string (Ascii.IsValid on net8+, a loop before that) is passed straight to ToUpperInvariant() / 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:

  • every BMP char (lone surrogates included) alone, between ASCII letters, doubled, after a high surrogate, before a low surrogate, and next to U+017F / U+212A / U+0130
  • every supplementary code point U+10000-U+10FFFF alone and in context
  • one string of all BMP chars (forwards and reversed) and one of all supplementary code points

Zero 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.

input net48 before net48 after net8.0 before net8.0 after net10.0 before net10.0 after
upper, 1M chars ASCII 8.46 ms 3.19 ms 2.30 ms 0.48 ms 4.24 ms 0.46 ms
lower, 1M chars ASCII 8.44 ms 2.93 ms 4.62 ms 0.53 ms 2.85 ms 0.55 ms
upper, 1M chars ASCII with é every 11 8.73 ms 4.06 ms 3.46 ms 3.65 ms 3.85 ms 3.49 ms
lower, 1M chars ASCII with é every 11 11.34 ms 3.50 ms 6.40 ms 5.83 ms 4.46 ms 3.95 ms
upper, 1M chars Cyrillic 49.59 ms 2.31 ms 12.68 ms 15.16 ms 14.48 ms 13.68 ms
lower, 1M chars Cyrillic 54.80 ms 18.03 ms 32.22 ms 26.95 ms 22.76 ms 17.35 ms
upper, "Content-Type" x 1M 93.89 ms 67.12 ms 24.64 ms 18.44 ms 21.66 ms 17.80 ms
lower, "Content-Type" x 1M 98.60 ms 74.32 ms 25.96 ms 18.79 ms 23.71 ms 16.93 ms

The 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 as char.ToUpperOrdinal / char.ToLowerOrdinal do (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+017F at the start, end, repeated, and between surrogate pairs
  • String_ToLowerOrdinal_PreservesOrdinalClass: KELVIN SIGN, OHM SIGN, ANGSTROM SIGN, capital sharp S and the capital theta symbol, alone and mixed with text and surrogate pairs
  • String_OrdinalCasing_Ascii: all 128 ASCII chars repeated, and ASCII text with a single non-ASCII char at the start, middle and end
  • String_OrdinalCasing_Surrogates (adjacent pairs, lone halves, a high surrogate before a pair), String_OrdinalCasing_Unchanged

@SimonCropp SimonCropp modified the milestones: 11.4.4, 11.4.5 Oct 12, 2026
@SimonCropp
SimonCropp merged commit 84dfe3c into main Oct 12, 2026
7 checks passed
@SimonCropp
SimonCropp deleted the perf/string-ordinal-casing branch October 12, 2026 12:13
This was referenced Oct 12, 2026
This was referenced Oct 14, 2026
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