Популярная задача — определить, состоит ли строка только из числовых символов. Например, нужно проверить, что пользователь правильно ввел номер телефона, индекс или ИНН организации. Сделать это можно несколькими способами, которые отличаются своей эффективностью. Давайте рассмотрим самые популярные из них.

Regex

Наверное, это наиболее часто встречающийся способ. И действительно, что может быть проще использования регулярного выражения вида ^[0-9]*$ (или ^\d*$)?

Ниже представлена наивная реализация проверки с помощью регулярного выражения:

Regex regex = new Regex("^[0-9]*$");
var value = "123456789000";
var isValid = regex.IsMatch(value);

Возможно, вы уже видите здесь проблему. Такая реализация годится только для одноразового запуска. В промышленном коде, где вы проверяете сотни тысяч строк, такое решение будет не эффективным..NET предоставляет возможность скомпилировать регулярное выражение во время выполнения при вызове конструктора, для это нужно использовать опцию RegexOptions.Compiled:

Regex regex = new Regex("^[0-9]*$", RegexOptions.Compiled);
var value = "123456789000";
var isValid = regex.IsMatch(value);

При вызове конструктора с этой опцией будет сгенерирован IL-код, который будет вызываться через DynamicMethod внутри Regex.IsMatch, что будет быстрее, чем обычная обработка регулярного выражения. Минусом же будет более долгое создание объекта Regex за счет затрат времени на компиляцию в рантайме, но это быстро окупается при многократном использовании.

Давайте сравним производительность двух вариантов.

Код бенчмарка. Нажмите, чтобы развернуть.
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
    private static string value = "123456789000";

    private static readonly Regex regex = new Regex("^[0-9]*$");
    private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);

    [Benchmark]
    public bool Regex()
    {
        return regex.IsMatch(value);
    }
    
    [Benchmark]
    public bool CompiledRegex()
    {
        return compiledRegex.IsMatch(value);
    }
}

Результаты:

| Method        | Runtime            |        Mean |      Median | Allocated |
|---------------|--------------------|------------:|------------:|----------:|
| Regex         | .NET Framework 4.8 | 165.4417 ns | 166.2537 ns |         - |
| CompiledRegex | .NET Framework 4.8 | 115.9377 ns | 115.9720 ns |         - |
| Regex         | .NET Core 3.1      | 118.1540 ns | 118.1887 ns |         - |
| CompiledRegex | .NET Core 3.1      |  89.7392 ns |  89.6514 ns |         - |
| Regex         | .NET 6.0           |  57.8247 ns |  57.8031 ns |         - |
| CompiledRegex | .NET 6.0           |  21.2952 ns |  21.2616 ns |         - |
| Regex         | .NET 9.0           |  47.2579 ns |  47.3506 ns |         - |
| CompiledRegex | .NET 9.0           |  24.2419 ns |  24.2547 ns |         - |
Regex vs Compiled Regex
Regex vs Compiled Regex

Преимущество использования скомпилированных выражений довольно наглядно. Так же с каждой новой версией .NET виден вклад разработчиков в производительность. Это еще один аргумент в пользу обновления на современные версии фреймворка.

Regex source generators

Повторюсь, что компиляция регулярных выражений имеет один недостаток — создание объекта Regex в рантайме будет занимать какое-то время. Можно ли от этого избавиться? Начиная с .NET 7 такая возможность появляется благодаря генераторам кода. Строго говоря, они появились в .NET 5, но решение для регулярных выражений было реализовано только в седьмой версии. Генераторы кода позволяют создавать C#-код на этапе компиляции. А значит его можно просматривать и дебажить так, словно это ваш собственный код. И регулярные выражения можно превратить в C#-код на этапе компиляции! В .NET для этого реализован специальный атрибут GeneratedRegex:

namespace DigitBenchmark
{
    public partial class DigitsBenchmarks
    {
        private static readonly Regex generatedRegex = GenerateRegex();
        
        [GeneratedRegex("^[0-9]*$")]
        private static partial Regex GenerateRegex();
    }
}

Давайте разберемся, что здесь происходит. Во-первых, нам нужно пометить наш класс DigitsBenchmarks как partial, т.к. часть сгенерированного кода для этого класса будет находиться в другом файле. Дальше нам нужно создать partial-метод, который будет возвращать объект типа Regex и пометить его атрибутом GeneratedRegex с указанием шаблона регулярного выражения. Опцию RegexOptions.Compiled указывать не нужно, она будет проигнорирована. Далее поймете почему.

Реализация метода GenerateRegex будет находиться в другом файле. Его можно найти в проекте и посмотреть исходный код:

namespace DigitBenchmark
{
    partial class DigitsBenchmarks
    {
        /// <remarks>
        /// Pattern:<br/>
        /// <code>^[0-9]*$</code><br/>
        /// Explanation:<br/>
        /// <code>
        /// ○ Match if at the beginning of the string.<br/>
        /// ○ Match a character in the set [0-9] atomically any number of times.<br/>
        /// ○ Match if at the end of the string or if before an ending newline.<br/>
        /// </code>
        /// </remarks>
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "8.0.12.21506")]
        private static partial global::System.Text.RegularExpressions.Regex GenerateRegex() => global::System.Text.RegularExpressions.Generated.GenerateRegex_0.Instance;
    }
}

Как видите, автоматически был создан файл с тем же классом и содержащий реализацию нашего метода по генерации регулярного выражения.А дальше мы можем пользоваться этим объектом Regex как обычно. Так как это настоящий C#-код, то генерировать в рантайме ничего не нужно, поэтому и указывать опцию RegexOptions.Compiled нет смысла.

Какое преимущество мы от этого получим? В моих бенчмарках нет версий .NET 7 и 8, сравним производительность по последней на данный момент:

| Method         | Runtime  |       Mean |     Median | Allocated |
|----------------|----------|-----------:|-----------:|----------:|
| Regex          | .NET 9.0 | 47.2579 ns | 47.3506 ns |         - |
| CompiledRegex  | .NET 9.0 | 24.2419 ns | 24.2547 ns |         - |
| GeneratedRegex | .NET 9.0 | 17.2548 ns | 17.2603 ns |         - |

Видим, что время сократилось почти на 30%! Компилятор имеет намного больше возможностей для оптимизации исходного кода на этапе компиляции, чем в рантайме.

char.IsDigit

Еще один популярный способ — использование статического метода char.IsDigit в сочетании с LINQ-методом All:

var value = "123456789000";
var isValid = value.All(char.IsDigit);

Давайте проверим, насколько хорош этот метод с точки зрения производительности:

| Method               | Runtime            |       Mean |     Median | Allocated |
|----------------------|--------------------|-----------:|-----------:|----------:|
| LinqCharIsDigit      | .NET Framework 4.8 | 92.1679 ns | 92.2549 ns |      96 B |
| LinqCharIsDigit      | .NET Core 3.1      | 72.0987 ns | 72.6419 ns |      96 B |
| LinqCharIsDigit      | .NET 6.0           | 74.2609 ns | 74.4256 ns |      96 B |
| LinqCharIsDigit      | .NET 9.0           | 31.0294 ns | 31.0501 ns |      32 B |

И сравним этот способ с предыдущими решениями.

Regex vs char.IsDigit
Regex vs char.IsDigit

Если в старых версиях фреймворка проверки через LINQ и метод IsDigit имеют преимущество над регулярными выражениями, то позже мы видим, что такая реализация начинает проигрывать.

Так же, обратите внимание, что каждый вызов такого метода приводит к выделению какого-то количества дополнительной памяти. Это значение складывается из 2-х составляющих:

  1. Создание лямбда-выражения в параметре метода All(c => char.IsDigit(c))

  2. Создание итератора внутри метода All

Примечательно, что в версии .NET 9 выделяется в 3 раза меньше памяти.До .NET 9 метод All был очень простым и состоял из цикла foreach с условием:

public static bool All<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
  //...
  foreach (TSource source1 in source)
  {
    if (!predicate(source1))
      return false;
  }
  return true;
}

Но в версии .NET 9 была добавлена важная оптимизация:

public static bool All<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
  //...
  ReadOnlySpan<TSource> span;
  if (source.TryGetSpan<TSource>(out span))
  {
    ReadOnlySpan<TSource> readOnlySpan = span;
    for (int index = 0; index < readOnlySpan.Length; ++index)
    {
      TSource source1 = readOnlySpan[index];
      if (!predicate(source1))
        return false;
    }
  }
  else
  {
    foreach (TSource source2 in source)
    {
      if (!predicate(source2))
        return false;
    }
  }
  return true;
}

Вместо безусловного цикла foreach метод All пробует получить из источника ReadOnlySpan - безопасный для чтения непрерывный блок памяти. И дальше используется простой цикл for, который не приводит к созданию итератора. Тем самым уменьшая количество дополнительной памяти. Полностью избавиться от этого можно переписав метод All на обычный цикл:

public bool ForIsDigit()
{
    for (var i = 0; i < value.Length; i++)
    {
        if (!char.IsDigit(value[i]))
            return false;
    }

    return true;
}

Помимо отсутствия лишнего memory-traffic данное решение является очень быстрым.

LINQ vs for-loop
LINQ vs for-loop

Что такое число?

Казалось бы, мы нашли оптимальное решение для проверки строки на наличие только чисел. Но предлагаю вам попробовать угадать, что выведет следующий код:

Console.WriteLine(char.IsDigit('0'));
Console.WriteLine(char.IsDigit('a'));
Console.WriteLine(char.IsDigit('٨'));
Console.WriteLine(char.IsDigit('৯'));
Нажмите, чтобы узнать ответ.
Console.WriteLine(char.IsDigit('0')); //True
Console.WriteLine(char.IsDigit('a')); //False
Console.WriteLine(char.IsDigit('٨')); //True
Console.WriteLine(char.IsDigit('৯')); //True

Думаю, вы удивлены результатом. Но в этом нет ничего необычного, метод IsDigit считает числами не только привычные нам символы из множества 0-9, но и все остальные символы, которые в кодировке Unicode относятся к числам. А их на самом деле много. Это может быть проблемой, если вы опираетесь на такую проверку в своём бизнес-коде.

Думаю, это послужило причиной появления нового метода char.IsAsciiDigit начиная с .NET 7. Вот он уже действительно проверяет только символы из множества 0-9.

Его реализация очень похожа на ручную проверку каждого символа в цикле, давай сравним оба варианта:

[Benchmark]
public bool ForCompare()
{
    for (var i = 0; i < value.Length; i++)
    {
        if (value[i] < '0' || value[i] > '9')
            return false;
    }

    return true;
}

[Benchmark]
public bool ForIsAsciiDigit()
{
    for (var i = 0; i < value.Length; i++)
    {
        if (!char.IsAsciiDigit(value[i]))
            return false;
    }

    return true;
}
| Method               | Runtime  |       Mean |     Median | Allocated |
|----------------------|----------|-----------:|-----------:|----------:|
| ForCompare           | .NET 9.0 |  4.8587 ns |  4.8656 ns |         - |
| ForIsAsciiDigit      | .NET 9.0 |  4.7515 ns |  4.4976 ns |         - |

Оба метода показывают эквивалентные результаты.

Заключение

Мы рассмотрели несколько разных способов проверить строку, состоит ли она только из чисел или нет. И важно понимать особенности работы некоторых из них, так как помимо банальных проблем производительности можно получить ошибки в бизнес-логике, если недостаточно качественно проверять входные данные.

Рекомендации:

  • Если вы пишете свои приложения под .NET 7 или выше, то используйте сгенерированные регулярные выражения. В противном случае указывайте опцию RegexOptions.Compiled.

  • Если вы пишете свои приложения под .NET 7 используйте метод char.IsAsciiDigit для проверки символов. В противном случае лучше написать проверку самому.

Комментарии (23)


  1. Gromilo
    07.08.2025 07:57

    За char.IsAsciiDigit спасибо.

    Но что вы там такое делаете, что приходиться бенчить проверки номера? Проверяете номера всего мира несколько раз в сутки?


    1. srogatnev Автор
      07.08.2025 07:57

      Я не скажу, что есть какие-то проблемы производительности в этом месте. А даже если они и есть, то это далеко не самая критичная часть кода. Это скорее любопытство и попытки лучше разобраться, как это работает. Ну и по возможности, где-то сделать лучше, если это дёшево: например, добавить опцию компиляции регулярного выражения или использовать source generator.


  1. OwDafuq
    07.08.2025 07:57

    Попробуйте еще это:

    [Benchmark] public bool IsAllDigit() { var length = str.Length; var span = str.AsSpan(); for (var i = 0; i &lt; length; i++) { if ((uint)(span[i] - '0') &gt; 9) { return false; } } return true; }

    Бенчмарк показывает, что мой метод быстрее, по крайней мере на моей машине

    (извините, не совсем умею на хабре вставлять куски кода правильно)


    1. srogatnev Автор
      07.08.2025 07:57

      Действительно, можно попробовать такое решение. У меня получились такие результаты:

      | Method                  | Runtime            | Mean      | Median    | Allocated |
      |------------------------ |------------------- |----------:|----------:|----------:|
      | IsAllDigit              | .NET Framework 4.8 | 6.3097 ns | 6.2725 ns |         - |
      | IsAllDigit              | .NET Core 3.1      | 4.4434 ns | 4.4404 ns |         - |
      | IsAllDigit              | .NET 6.0           | 4.4595 ns | 4.4046 ns |         - |
      | IsAllDigit              | .NET 9.0           | 3.4871 ns | 3.5038 ns |         - |

      Можно сказать, что он сопоставим с методом IsAsciiDigit и лишь в .NET 9 появляется преимущество.


      1. OwDafuq
        07.08.2025 07:57

        Если тут такая пьянка пошла, то может подскажите как можно быстро удалить пробелы из текста? То есть вообще все пробелы, Replace(' ', '\0') выглядит как-то не очень


        1. srogatnev Автор
          07.08.2025 07:57

          Тоже популярная задача, кстати. Не знаю, какой вариант будет самым быстрым. Используют и string.Replace и Regex.Replace. Надо поискать еще варианты и сравнить, чтобы обоснованно ответить.

          Возможно, это выльется в статью на хабре. Если нет, то напишу заметку в блог себе.


          1. OwDafuq
            07.08.2025 07:57

            Regex заметно медленее работает, бенчмаркал его в первую очередь


  1. Erty_Hackward
    07.08.2025 07:57

    return value.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;


    1. srogatnev Автор
      07.08.2025 07:57

      Метод IndexOfAnyExceptInRange появился в .NET 8, поэтому в моём бенчмарке будет такой результат:

      | Method                  | Runtime            | Mean      | Median    | Allocated |
      |------------------------ |------------------- |----------:|----------:|----------:|
      | IndexOfAnyExceptInRange | .NET 9.0           | 1.9695 ns | 1.9765 ns |         - |

      Еще предположил, что простая конвертация AsSpan() может дать прирост, поэтому в бенчмарк добавил преобразование:

      Код бенчмарка
      [Benchmark]
      public bool ForCompare()
      {
      	var span = digitsOnly.AsSpan();
      	for (var i = 0; i < span.Length; i++)
      	{
      		if (span[i] < '0' || span[i] > '9')
      			return false;
      	}
      
      	return true;
      }
      
      [Benchmark]
      public bool ForIsAsciiDigit()
      {
      	var span = digitsOnly.AsSpan();
      	for (var i = 0; i < span.Length; i++)
      	{
      		if (!char.IsAsciiDigit(span[i]))
      			return false;
      	}
      
      	return false;
      }
      
      [Benchmark]
      public bool IndexOfAnyExceptInRange()
      {
      	return digitsOnly.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;
      }

      Получились результаты лучше оригинальных, но всё еще проигрывают IndexOfAnyExceptInRange:

      | Method                  | Runtime            | Mean       | Median     | Allocated |
      |------------------------ |------------------- |-----------:|-----------:|----------:|
      | ForCompare              | .NET 9.0           |  2.7351 ns |  2.7276 ns |         - |
      | ForIsAsciiDigit         | .NET 9.0           |  2.6761 ns |  2.7066 ns |         - |
      | IndexOfAnyExceptInRange | .NET 9.0           |  1.9695 ns |  1.9765 ns |         - |


  1. VBDUnit
    07.08.2025 07:57

    Для интереса попробовал на интринсиках (.NET9/Release):

    | Method                             | Mean              | Error          | StdDev         |
    |----------------------------------- |------------------:|---------------:|---------------:|
    | LEN_12_only_digits                 |          1.668 ns |      0.0088 ns |      0.0083 ns |
    | LEN_12_NOT_only_digits             |          1.099 ns |      0.0062 ns |      0.0055 ns |
    | LEN_256_000_000_only_digits        | 10,137,419.231 ns | 77,538.9046 ns | 64,748.4971 ns |
    | LEN_256_000_000_NOT_only_digits    |  9,201,213.951 ns | 92,295.2075 ns | 81,817.2974 ns |
    • Строки в 12 символов за 1.1 — 1.7 нс

    • Строки в 256 миллионов символов — 10–11 мс

    public static bool IsAllDigits([NotNull] string str)
    {
        unsafe
        {
            fixed (char* begin = str)
            {
                char* ptr = begin;
                var charsInVector = Vector<ushort>.Count;
                if (str.Length >= charsInVector) //Если строка достаточно длинная для векторов
                {
                    //Вычисляем конец чтобы поместилось целое число векторов
                    char* endVector = begin + str.Length / charsInVector * charsInVector;
    
                    var min = new Vector<ushort>((ushort)'0'); //Вектор состоящий из '0'
                    var max = new Vector<ushort>((ushort)'9'); //Вектор состоящий из '9'
    
                    //Идея простая: берем пачки по N символов
                    //загоняем в диапазон от '0' до '9'
                    //и смотрим есть ли изменения - если есть, значит это были не цифры
                    while (ptr < endVector)
                    {
                        var v = Vector.Load((ushort*)ptr); //Загружаем 16 символов в регистр 
                        var vClamped = Vector.ClampNative(v, min, max); //Загоняем в диапазон от '0' до '9'
    
                        if (!Vector.EqualsAll(v, vClamped)) //Если что-то изменилось то там были не только цифры
                            return false;
    
                        ptr += charsInVector; //Идем дальше
                    }
                }
                char* end = begin + str.Length;
    
                //Если осталось хотя бы 8 символов - добиваем 128-битной инструкцией
                if (end - ptr >= Vector128<ushort>.Count)
                {
                    var min128 = Vector128.Create((ushort)'0');
                    var max128 = Vector128.Create((ushort)'9');
                    var v = Vector128.Load<ushort>((ushort*)ptr);
                    if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                        return false;
                    else
                        ptr += Vector128<ushort>.Count;
                }
               
                //Далее анролл по 4
                char* end4 = begin + (str.Length & ~3);
                while (ptr < end4)
                {
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                }
    
                //Потом обычным циклом
                while (ptr < end)
                {
                    if (unchecked((uint)(*ptr - '0')) > 9u)
                        return false;
                    ptr++;
                }
            }
        }
        return true;
    }
    Использование AVX512 прироста не дало
    | Method                             | Mean              | Error          | StdDev         |
    |----------------------------------- |------------------:|---------------:|---------------:|
    | LEN_12_only_digits512              |          1.676 ns |      0.0078 ns |      0.0073 ns |
    | LEN_12_NOT_only_digits512          |          1.101 ns |      0.0063 ns |      0.0059 ns |
    | LEN_256_000_000_only_digits512     | 10,133,206.250 ns | 77,341.0857 ns | 68,560.8580 ns |
    | LEN_256_000_000_NOT_only_digits512 |  9,085,707.091 ns | 53,953.1981 ns | 45,053.3640 ns |
      public static bool IsAllDigits512([NotNull] string str)
      {
          unsafe
          {
              fixed (char* begin = str)
              {
                  char* ptr = begin;
                  var charsInVector512 = Vector512<ushort>.Count;
                  //Основная тушка с векторами
                  if (str.Length >= charsInVector512) //Если строка достаточно длинная для векторов
                  {
                      //Вычисляем конец чтобы поместилось целое число векторов
                      char* endVector512 = begin + str.Length / charsInVector512 * charsInVector512;
    
                      var min = Vector512.Create((ushort)'0'); //Вектор состоящий из '0'
                      var max = Vector512.Create((ushort)'9'); //Вектор состоящий из '9'
    
                      //Идея простая: берем пачки по N символов
                      //загоняем в диапазон от '0' до '9'
                      //и смотрим есть ли изменения - если есть, значит это были не цифры
                      while (ptr < endVector512)
                      {
                          var v = Vector512.Load((ushort*)ptr); //Загружаем 16 символов в регистр 
                          var vClamped = Vector512.ClampNative(v, min, max); //Загоняем в диапазон от '0' до '9'
    
                          if (!Vector512.EqualsAll(v, vClamped)) //Если что-то изменилось то там были не только цифры
                              return false;
    
                          ptr += charsInVector512; //Идем дальше
                      }
                  }
    
                  char* end = begin + str.Length;
    
                  //Если осталось хотя бы 8 символов - добиваем 128-битной инструкцией
                  if (end - ptr >= Vector128<ushort>.Count)
                  {
                      var min128 = Vector128.Create((ushort)'0');
                      var max128 = Vector128.Create((ushort)'9');
                      var v = Vector128.Load<ushort>((ushort*)ptr);
                      if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                          return false;
                      else
                          ptr += Vector128<ushort>.Count;
                  }
    
                  //Далее анролл по 4
                  char* end4 = begin + (str.Length & ~3);
                  while (ptr < end4)
                  {
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                  }
    
                  //Потом обычным циклом
                  while (ptr < end)
                  {
                      if (unchecked((uint)(*ptr - '0')) > 9u)
                          return false;
                      ptr++;
                  }
              }
          }
          return true;
      }
    Код бенчмарка
     public class Benchmark
     {
         static int randomIndex = 0;
         static string generateBigRandomString(bool onlyDigits, int count)
         {
             if (count < 1024)
                 throw new NotSupportedException();
             var rnd = new Random(Interlocked.Increment(ref randomIndex));
             var b = new StringBuilder(count);
             var beginNoDigits = count / 10 * 9;
             for (int i = 0; i < count; i++)
                 b.Append((char)rnd.Next('0' + 0, '9' + 1));
    
             if (!onlyDigits)
             {
                 for (int i = 0; i < 10; i++)
                 {
                     var index = rnd.Next(beginNoDigits, count);
                     b[index] = (char)rnd.Next('a' + 0, 'z' + 1);
                 }
             }
             return b.ToString();
         }
         string small_only_digits, small;
         string big_only_digits, big;
         [GlobalSetup]
         public void Setup()
         {
             small_only_digits = "123456732890";
             small = "123456732a90";
             big_only_digits = generateBigRandomString(true, 256_000_000);
             big = generateBigRandomString(false, 256_000_000);
         }
    
         [Benchmark] public bool LEN_12_only_digits() => TurboIsDigitChecker.IsAllDigits(small_only_digits);
         [Benchmark] public bool LEN_12_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(small);
         [Benchmark] public bool LEN_256_000_000_only_digits() => TurboIsDigitChecker.IsAllDigits(big_only_digits);
         [Benchmark] public bool LEN_256_000_000_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(big);
    
         [Benchmark] public bool LEN_12_only_digits512() => TurboIsDigitChecker.IsAllDigits512(small_only_digits);
         [Benchmark] public bool LEN_12_NOT_only_digits512() => TurboIsDigitChecker.IsAllDigits512(small);
         [Benchmark] public bool LEN_256_000_000_only_digits512() => TurboIsDigitChecker.IsAllDigits512(big_only_digits);
         [Benchmark] public bool LEN_256_000_000_NOT_only_digits512() => TurboIsDigitChecker.IsAllDigits512(big);
     }

    Дальше можно в зависимости от фактического размера данных затачиваться:

    • Под короткие строки — тогда выкидываем тушу с векторами и делаем ступенчатую обработку (по типу блока с Vector128, который под тушкой векторов), то есть сначала один раз без цикла проверяем 512, потом 256, потом 128 бит, потом анролл, потом обычный цикл

    • Под длинные строки — тогда можно перед тушкой с векторами поставить проверку по 1 цифре, которая подравняет указатель памяти до кратности размеру вектора, и далее, в тушке векторов, делать не Load, а LoadAligned, что немного быстрее. Ну и можно поиграться со способом проверки — не Clamp/Equals, а какие‑нибудь хитрые трюки с масками


    1. srogatnev Автор
      07.08.2025 07:57

      Ух, кажется вы выжали всё возможное. Интересная реализация, спасибо за примеры.


    1. Nagg
      07.08.2025 07:57

      Ваша реализация всё ещё проигрывает озвученному выше return value.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;


      1. VBDUnit
        07.08.2025 07:57

        Хм. У меня наоборот получается, что немного выигрывает:

        | Method                               | Mean              | Error           | StdDev          |
        |------------------------------------- |------------------:|----------------:|----------------:|
        | LEN_12_only_digits                   |          1.652 ns |       0.0026 ns |       0.0023 ns |
        | LEN_12_NOT_only_digits               |          1.093 ns |       0.0019 ns |       0.0015 ns |
        | LEN_256_000_000_only_digits          | 10,282,478.846 ns |  60,604.1205 ns |  50,607.1854 ns |
        | LEN_256_000_000_NOT_only_digits      |  9,564,862.960 ns | 180,272.5571 ns | 185,126.6438 ns |
        | SPAN_LEN_12_only_digits              |          1.656 ns |       0.0049 ns |       0.0046 ns |
        | SPAN_LEN_12_NOT_only_digits          |          1.897 ns |       0.0014 ns |       0.0012 ns |
        | SPAN_LEN_256_000_000_only_digits     | 10,340,051.146 ns | 181,664.4755 ns | 169,929.0632 ns |
        | SPAN_LEN_256_000_000_NOT_only_digits |  9,199,522.098 ns |  90,575.2054 ns |  80,292.5604 ns |
        Код
         public static bool IsAllDigitsSpan([NotNull] string str) =>
           str.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;
         public static bool IsAllDigits([NotNull] string str)
         {
             unsafe
             {
                 fixed (char* begin = str)
                 {
                     char* ptr = begin;
                     var charsInVector = Vector<ushort>.Count;
                     if (str.Length >= charsInVector) //Если строка достаточно длинная для векторов
                     {
                         //Вычисляем конец чтобы поместилось целое число векторов
                         char* endVector = begin + str.Length / charsInVector * charsInVector;
        
                         var min = new Vector<ushort>((ushort)'0'); //Вектор состоящий из '0'
                         var max = new Vector<ushort>((ushort)'9'); //Вектор состоящий из '9'
        
                         //Идея простая: берем пачки по N символов
                         //загоняем в диапазон от '0' до '9'
                         //и смотрим есть ли изменения - если есть, значит это были не цифры
                         while (ptr < endVector)
                         {
                             var v = Vector.Load((ushort*)ptr); //Загружаем 16 символов в регистр 
                             var vClamped = Vector.ClampNative(v, min, max); //Загоняем в диапазон от '0' до '9'
        
                             if (!Vector.EqualsAll(v, vClamped)) //Если что-то изменилось то там были не только цифры
                                 return false;
        
                             ptr += charsInVector; //Идем дальше
                         }
                     }
                     char* end = begin + str.Length;
        
                     //Если осталось хотя бы 8 символов - добиваем 128-битной инструкцией
                     if (end - ptr >= Vector128<ushort>.Count)
                     {
                         var min128 = Vector128.Create((ushort)'0');
                         var max128 = Vector128.Create((ushort)'9');
                         var v = Vector128.Load<ushort>((ushort*)ptr);
                         if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                             return false;
                         else
                             ptr += Vector128<ushort>.Count;
                     }
        
                     //Далее анролл по 4
                     char* end4 = begin + (str.Length & ~3);
                     while (ptr < end4)
                     {
                         if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                         if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                         if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                         if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                     }
        
                     //Потом обычным циклом
                     while (ptr < end)
                     {
                         if (unchecked((uint)(*ptr - '0')) > 9u)
                             return false;
                         ptr++;
                     }
                 }
             }
             return true;
         }
           [Benchmark] public bool LEN_12_only_digits() => TurboIsDigitChecker.IsAllDigits(small_only_digits);
           [Benchmark] public bool LEN_12_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(small);
           [Benchmark] public bool LEN_256_000_000_only_digits() => TurboIsDigitChecker.IsAllDigits(big_only_digits);
           [Benchmark] public bool LEN_256_000_000_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(big);
        
           [Benchmark] public bool SPAN_LEN_12_only_digits() => TurboIsDigitChecker.IsAllDigitsSpan(small_only_digits);
           [Benchmark] public bool SPAN_LEN_12_NOT_only_digits() => TurboIsDigitChecker.IsAllDigitsSpan(small);
           [Benchmark] public bool SPAN_LEN_256_000_000_only_digits() => TurboIsDigitChecker.IsAllDigitsSpan(big_only_digits);
           [Benchmark] public bool SPAN_LEN_256_000_000_NOT_only_digits() => TurboIsDigitChecker.IsAllDigitsSpan(big);

        Предположу, что дело в особенностях реализации SIMD на конкретном железе, размерах кэша, скорости ОЗУ и прочих штуках, поэтому разные подходы дают разные результаты на разном железе. И Spanовская реализация, судя по всему, написана примерно так же, но оказалась лучше заточена под Ваше железо, и хуже под моё.

        UPD: вариант, заточенный под мелкие строки:

        | Method                       | Mean      | Error     | StdDev    |
        |----------------------------- |----------:|----------:|----------:|
        | LEN_12_only_digits_SMALL     | 1.4944 ns | 0.0083 ns | 0.0077 ns |
        | LEN_12_NOT_only_digits_SMALL | 0.9210 ns | 0.0088 ns | 0.0083 ns |
        | LEN_12_only_digits_SPAN      | 1.7246 ns | 0.0053 ns | 0.0047 ns |
        | LEN_12_NOT_only_digits_SPAN  | 1.8502 ns | 0.0058 ns | 0.0055 ns |
        Код
           public static bool IsAllDigitsSpan([NotNull] string str) =>
                    str.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;
           public static bool IsAllDigits_FOR_SMALL([NotNull] string str)
           {
               unsafe
               {
                   fixed (char* begin = str)
                   {
                       char* ptr = begin;
                       char* end = begin + str.Length;
        
                       //8 символов
                       if (end - ptr >= Vector128<ushort>.Count)
                       {
                           var min128 = Vector128.Create((ushort)'0');
                           var max128 = Vector128.Create((ushort)'9');
                           var v = Vector128.Load<ushort>((ushort*)ptr);
                           if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                               return false;
                           else
                               ptr += Vector128<ushort>.Count;
                       }
        
                       //4 символа
                       char* end4 = begin + (str.Length & ~3);
                       if (ptr < end4)
                       {
                           if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                           if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                           if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                           if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                       }
        
                       //Добиваем обычным циклом
                       while (ptr < end)
                       {
                           if (unchecked((uint)(*ptr - '0')) > 9u)
                               return false;
                           ptr++;
                       }
                   }
               }
               return true;
           }
          [Benchmark] public bool LEN_12_only_digits_SMALL() => TurboIsDigitChecker.IsAllDigits_FOR_SMALL(small_only_digits);
          [Benchmark] public bool LEN_12_NOT_only_digits_SMALL() => TurboIsDigitChecker.IsAllDigits_FOR_SMALL(small);
          [Benchmark] public bool LEN_12_only_digits_SPAN() => TurboIsDigitChecker.IsAllDigitsSpan(small_only_digits);
          [Benchmark] public bool LEN_12_NOT_only_digits_SPAN() => TurboIsDigitChecker.IsAllDigitsSpan(small);


  1. matricarin
    07.08.2025 07:57

    Спасибо!


  1. R3b0rN
    07.08.2025 07:57

    Мне кажется, если важна скорость выполнения, то регулярные выражения стоит применять в самом крайнем случае. Так или иначе, они всегда будут тяжелей какого-то узко-специализированного метода, т.к это универсальный инструмент.


    1. nightwolf_du
      07.08.2025 07:57

      Регулярное выражение такого вида вырождается в очень простой конечный автомат.

      Собственно, насколько я вижу из этой статьи даже создание итератора уже дороже чем такая регулярка(компилированная). Быстрее только for и unsafe.

      Лично я для поиска без групп и заглядывания вперед-назад по строке уже лет 5 как перестал писать конечные автоматы руками. Выигрыш того обычно не стоит


  1. LaRN
    07.08.2025 07:57

    А всякие Int32.Parse насколько хуже чем регулярки?


    1. nightwolf_du
      07.08.2025 07:57

      Если бросит эксепшон - потеряете от микросекунд до миллисекунд в зависимости от кучи факторов. Для маленьких строк это задержка минимум на 2 порядка.

      А большие строки у вас в int не влезут


      1. LaRN
        07.08.2025 07:57

        Ну я это к тому, что сам факт состоит строка только из цифр или нет сам по себе не очень ценен, следом же скорее всего будет вызван конверт в число, а тут сразу имеем это число или эсцепшен, когда не число.


        1. srogatnev Автор
          07.08.2025 07:57

          Во многих случаях хранение таких данных как чисел невозможно. Например,трудно отличать значение 00123 от 123. Так же, какой-нибудь номер банковской карты не влезает в стандартные числовые типы данных, поэтому это всё хранится как строка. И тогда важно уметь валидировать входные данные без преобразований.


          1. LaRN
            07.08.2025 07:57

            А за счет чего быстрее выходит?

            Глобально, в любом случае, нужно пройти всю строку и каждый символ чекнуть на принадлежность диапазону >= '0' and <= '9'.

            Тут сравнивается скорость скомпиленного в натив кода с кодом выполненным виртуальной машиной?


  1. AnonimYYYs
    07.08.2025 07:57

    Объясните пожалуйста не знающему .НЕТ мне, такой нюанс. Я правильно понимаю, что внутри бенчмарка создается один раз регулярка, один раз используется, удаляется, опять создается, опять один раз используется, опять удаляется и так по новой? А в пункте 2, с флагом компиляции, мы как бы говорим компилятору при закрытии области видимости не удалятт переменную, так?

    Просто, если не так, то я видимо совсем не разбираюсь, а если так, то не понимаю сакрального смысла почему бы не использовать глобальную переменную вне цикла?


    1. Nagg
      07.08.2025 07:57

      У регулярок есть 3 режима:

      1. Обычное интерпретирование заданных правил из строки (поведение по-умолчанию)

      2. Динамического создания нового IL метода который оптимизирует какие-то операции конкретно под заданную регулярку.

      3. Тоже самое что 2) только через Source-Gen на этапе компиляции C# кода, а не как в случае 2) - во время работы

      В целом рекомендуется использовать 3ий вариант, он самый быстрый и вы не делаете лишней работы во время запуска приложения (= ускорение запуска) + дебажить просто. Есть всякие автоматические инспекции которые помогут привести обычную регулярку к нему

      Как видите, никакое сохранение ни в какую переменную само по себе ничего не дает (кроме разве что сохранения аллокации 1 объекта)