How effectively detect surrogate pair in a string?

Viewed 84

I need to detect surrogate pairs in hundreds of thousands of strings of various lengths.

I wonder what might be the fastest and most efficient way to do this.

    string srcString = " = ";
    var hasSurrogate = srcString.Any(c => '\uD800' <= c && c <= '\uDFFF');

Any works fine with its O(n) speed, but maybe there is a more efficient way to do this

2 Answers

The best thing you can do is measure. Have a couple implementations and benchmark them. I've compared using Any() vs. for. A simple for cycle is faster by a factor of 10.

Method Mean Error StdDev
HasSurrogateAny 8,095.7 ns 160.38 ns 230.01 ns
HasSurrogateFor 765.8 ns 14.19 ns 13.27 ns

I've used BenchmarkDotNet with this simple code:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace PerfTests.Console;

public class Program
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<SurrogateBenchmark>();
    }
}

public class SurrogateBenchmark
{
    private static string test = "".PadRight(1000) + " = ";

    [Benchmark]
    public bool HasSurrogateAny()
    {
        return test.Any(c => '\uD800' <= c && c <= '\uDFFF');
    }

    [Benchmark]
    public bool HasSurrogateFor()
    {
        for (int i = 0; i < test.Length; i++)
        {
            char c = test[i];
            if ('\uD800' <= c && c <= '\uDFFF') return true;
        }
        return false;
    }
}

Note that I've prepended 1000 spaces before the actual surrogate, so that the algorithms have some work to do and do not encounter a surrogate as the very first character in the string.

Environment: .NET 6.0.7, Intel Xeon Gold 16-core 2.4 GHz, WS2019 virtual machine

It turns out that a foreach offers essentially the same performance, as well as a more readable version which uses Char.IsSurrogate():

Method Mean Error StdDev
HasSurrogateAny 8,630.1 ns 134.03 ns 125.37 ns
HasSurrogateForEach 770.1 ns 10.31 ns 9.14 ns
HasSurrogateForReadable 796.0 ns 13.41 ns 12.54 ns
HasSurrogateFor 773.2 ns 11.46 ns 10.72 ns

And, finally, in my setup a version utilizing a for over a Span<char> is actually a bit slower:

Method Mean Error StdDev
HasSurrogateFor 764.1 ns 12.99 ns 11.52 ns
HasSurrogateForSpan 803.7 ns 15.95 ns 21.30 ns

Code for the remaining three tests:

[Benchmark]
public bool HasSurrogateForEach()
{
    foreach (char c in test)
    {
        if ('\uD800' <= c && c <= '\uDFFF') return true;
    }
    return false;
}

[Benchmark]
public bool HasSurrogateForReadable()
{
    for (int i = 0; i < test.Length; i++)
    {
        char c = test[i];
        if (Char.IsSurrogate(c)) return true;
    }
    return false;
}

[Benchmark]
public bool HasSurrogateForSpan()
{
    var s = test.AsSpan();
    for (int i = 0; i < s.Length; i++)
    {
        char c = test[i];
        if ('\uD800' <= c && c <= '\uDFFF') return true;
    }
    return false;
}

You can't beat the complexity of O(N) because you have to inspect every character. But you can speed things up considerably by using a loop and Span<Char> - and by "considerably" I mean more than 10 times faster.

The fastest method I could come up with is this:

static bool searchUsingSpanIsSurrogate(string s)
{
    var span = s.AsSpan();

    foreach (var c in span)
    {
        if (char.IsSurrogate(c))
            return true;
    }

    return false;
}

Out of interest I tried a few different methods. Here's the benchmark:

using System;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace Demo;

public class Program
{
    public static void Main()
    {
        var summary = BenchmarkRunner.Run<UnderTest>();
    }
}

public class UnderTest
{
    [Benchmark]
    public void SearchUsingLinq()
    {
        _ = searchUsingLinq(_haystack);
    }

    [Benchmark]
    public void SearchUsingSpan()
    {
        _ = searchUsingSpan(_haystack);
    }

    [Benchmark]
    public void SearchUsingLinqIsSurrogate()
    {
        _ = searchUsingLinqIsSurrogate(_haystack);
    }

    [Benchmark]
    public void SearchUsingSpanIsSurrogate()
    {
        _ = searchUsingSpanIsSurrogate(_haystack);
    }

    [Benchmark]
    public void SearchUsingIndexedIsSurrogate()
    {
        _ = searchUsingIndexedIsSurrogate(_haystack);
    }

    [Benchmark]
    public void SearchUsingLoop()
    {
        _ = searchUsingLoop(_haystack);
    }

    [Benchmark]
    public void SearchUsingLoopIsSurrogate()
    {
        _ = searchUsingLoop(_haystack);
    }

    static bool searchUsingLinq(string s)
    {
        return s.Any(c => '\uD800' <= c && c <= '\uDFFF');
    }

    static bool searchUsingSpan(string s)
    {
        var span = s.AsSpan();

        foreach (var c in span)
        {
            if ('\uD800' <= c && c <= '\uDFFF')
                return true;
        }

        return false;
    }

    static bool searchUsingLinqIsSurrogate(string s)
    {
        return s.Any(char.IsSurrogate);
    }

    static bool searchUsingSpanIsSurrogate(string s)
    {
        var span = s.AsSpan();

        foreach (var c in span)
        {
            if (char.IsSurrogate(c))
                return true;
        }

        return false;
    }

    static bool searchUsingIndexedIsSurrogate(string s)
    {
        for (int i = 0; i < s.Length; ++i)
        {
            if (char.IsSurrogate(s, i))
                return true;
        }

        return false;
    }

    static bool searchUsingLoop(string s)
    {
        foreach (var c in s)
        {
            if ('\uD800' <= c && c <= '\uDFFF')
                return true;
        }

        return false;
    }

    static bool searchUsingLoopIsSurrogate(string s)
    {
        foreach (var c in s)
        {
            if (char.IsSurrogate(c))
                return true;
        }

        return false;
    }

    readonly string _haystack = new string('X', 100_000);
}

And here's the results:

|                        Method |      Mean |     Error |    StdDev |    Median |
|------------------------------ |----------:|----------:|----------:|----------:|
|               SearchUsingLinq | 626.05 us |  9.130 us |  8.094 us | 625.55 us |
|               SearchUsingSpan | 102.21 us |  1.851 us |  1.732 us | 101.94 us |
|    SearchUsingLinqIsSurrogate | 746.70 us | 17.373 us | 49.284 us | 735.55 us |
|    SearchUsingSpanIsSurrogate |  53.00 us |  1.260 us |  3.675 us |  51.85 us |
| SearchUsingIndexedIsSurrogate |  62.64 us |  2.638 us |  7.736 us |  58.60 us |
|               SearchUsingLoop |  64.02 us |  1.581 us |  4.537 us |  62.19 us |
|    SearchUsingLoopIsSurrogate |  62.79 us |  1.228 us |  3.103 us |  61.47 us |

It looks like it doesn't really matter too much which of the non-Linq approaches you use, since they are all quite close in performance. Just don't use Linq if you want maximum performance, since the overhead of using a delegate is quite high.

Related