DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

StartsWith Was Asking ICU Every Time

I had an endpoint that sorts log lines as they come off a queue: route the ERROR ones one way, everything else another. The hot loop is one check, does this line start with "ERROR", and I'd written line.StartsWith("ERROR") without a second thought. It's the most obvious thing you can type.

Then I had the service under load with a profiler open, and a surprising slice of time was parked inside CompareInfo and the globalization stack underneath it. For a prefix check on ASCII log lines. That's when it clicked that StartsWith(string) doesn't do what I thought it did.

The default overload is linguistic, not literal

Here's the thing I'd forgotten. string.StartsWith(string) and string.IndexOf(string), the overloads that take only a string, don't compare characters. They compare text, using the current culture's rules, which on most boxes means routing through ICU for a full linguistic comparison. That's the machinery that knows "ff" and "ff" are the same word, that a soft hyphen shouldn't count, that casing folds differently in Turkish. Handy when you're sorting names a person will read. Total overkill when you're asking whether a byte string begins with five known ASCII letters.

The fix is to say what you actually meant:

line.StartsWith("ERROR", StringComparison.Ordinal)
Enter fullscreen mode Exit fullscreen mode

Ordinal means compare the char values, in order, and stop. No culture, no collation table, no ICU.

I counted it

I don't trust my gut on this stuff, so I wrote the smallest thing that would show the gap. Two million log-ish lines in an array, five prefixes mixed in so the comparison has to actually look at characters:

string[] prefixes = { "INFO ", "WARN ", "DEBUG ", "ERROR ", "TRACE " };
var lines = new string[2_000_000];
for (int i = 0; i < lines.Length; i++)
    lines[i] = prefixes[i % prefixes.Length] + "request " + i + " finished in " + (i % 400) + "ms";
Enter fullscreen mode Exit fullscreen mode

Then count how many start with "ERROR", the polite way and the ordinal way:

static long CountCulture(string[] lines)
{
    long n = 0;
    foreach (string s in lines)
        if (s.StartsWith("ERROR")) n++;                       // CurrentCulture
    return n;
}

static long CountOrdinal(string[] lines)
{
    long n = 0;
    foreach (string s in lines)
        if (s.StartsWith("ERROR", StringComparison.Ordinal)) n++;
    return n;
}
Enter fullscreen mode Exit fullscreen mode

The harness is nothing fancy. A stopwatch, a warmup call for each path so the JIT and the ICU tables are hot, then the best time out of seven, with the allocation counter read around each pass:

static long Measure(Action a, out long bestMs)
{
    bestMs = long.MaxValue;
    long bytes = 0;
    var sw = new Stopwatch();
    for (int run = 0; run < 7; run++)
    {
        long before = GC.GetAllocatedBytesForCurrentThread();
        sw.Restart();
        a();
        sw.Stop();
        bytes = GC.GetAllocatedBytesForCurrentThread() - before;
        if (sw.ElapsedMilliseconds < bestMs) bestMs = sw.ElapsedMilliseconds;
    }
    return bytes;
}
Enter fullscreen mode Exit fullscreen mode

It's a single-file console app on .NET 10, top-level statements, nothing else in it. Here's what came back:

StartsWith (culture) :    27 ms
StartsWith (ordinal) :    18 ms
ratio                : 1.5x
allocated (culture)  : 0 bytes
allocated (ordinal)  : 0 bytes
Enter fullscreen mode Exit fullscreen mode

That was a bit of a letdown, if I'm honest. I'd built this up in my head as the villain and it came in at 1.5x. Real, repeatable, but not the kind of number that changes your afternoon. And neither path allocates a byte, so this is pure CPU, no garbage to chase.

Then I ran the same question through IndexOf, because plenty of my "does this contain" checks are written the same lazy way:

IndexOf (culture)    :   109 ms
IndexOf (ordinal)    :    30 ms
ratio                : 3.6x
Enter fullscreen mode Exit fullscreen mode

There's the one that was actually hurting. A substring search under a linguistic comparison is a lot more work than a prefix check. It can't just glance at the front, it has to weigh matches at every position with full collation rules. Roughly 3.6x on the same data. That's the call I should have been worried about from the start.

These are ratios off a Linux container, not a lab bench with the clocks pinned, so the absolute milliseconds will land somewhere else on your hardware. The shape holds up though: ordinal is cheaper, and substring search is cheaper by a lot more than a prefix check.

Ordinal isn't a free find-and-replace

Here's the caveat, and it's a real one. Ordinal and culture don't always give the same answer. That's the entire reason they're separate methods, and it's why you can't just sweep the codebase swapping one for the other.

string shy = "co­op.log";                       // a soft hyphen sits between "co" and "op"
shy.StartsWith("coop");                               // True  - culture treats the hyphen as invisible
shy.StartsWith("coop", StringComparison.Ordinal);     // False - ordinal sees the hidden char
Enter fullscreen mode Exit fullscreen mode

Culture-aware comparison treats that soft hyphen as nothing, so it matches "coop". Ordinal sees nine characters and says no. For log routing, ordinal is exactly what I want: the literal content, invisible characters and all. For anything a human typed and expects to match "naturally", though, like search boxes, usernames, display sorting, the linguistic version is the correct one, and the extra cost is the price of being correct.

My rule of thumb now: if the strings are protocol rather than prose, so headers, enum names, file extensions, log levels, anything machine-generated, reach for Ordinal or OrdinalIgnoreCase by default. If a person wrote it and a person will read it, leave the culture-aware comparison where it is. The analyzer CA1310 flags exactly these call sites if you switch it on, which beats grepping for them by hand.

The quiet irony is that ordinal is both faster and usually what you meant for this kind of data. It's just not the default, and the default looks so harmless sitting there.

So what's the laziest string comparison hiding in your hot path? Check the Contains calls first, those are IndexOf underneath.

— still benchmarking corners of the standard library nobody asked me to

Top comments (0)