Подписывайтесь на наш Telegram и не пропускайте важные новости! Перейти

Гайд Интерполяция строк в C# без аллокаций в куче | .NET 10

Уникальная группа
Уникальная группа
Статус
Оффлайн
Регистрация
24 Сен 2024
Сообщения
64
Реакции
19
Поздравляю всех с новым годом. Пусть этот год принесёт вам всем только счастье и добро!

Долгое время манипуляции с текстом в .NET были синонимом нагрузки на сборщик мусора. Каждый раз, когда вы писали $"Score: {score}", происходило маленькое преступление против производительности:

  1. Выделялась память под строку в UTF-16
  2. Число score боксилось или конвертировалось через промежуточный буфер.
  3. Если вам нужен был UTF-8 (для сети или графических библиотек типа Raylib/OpenGL), происходил транскодинг - создание еще одного массива байтов.

В высоконагруженных системах это буквально заваливало Gen0 мусорными строками, провоцируя фризы. С выходом современных версий .NET и метода System.Text.Unicode.Utf8.TryWrite, этот подход официально признан устаревшим.

Что такое Utf8.TryWrite?


Это статический метод, который позволяет выполнять интерполяцию строк напрямую в целевой буфер в формате UTF-8. Секрет состоит в том, что он не использует string.Format. Вместо этого компилятор C# задействует специальный обработчик интерполированных строк (TryWriteInterpolatedStringHandler). Весь процесс происходит без создания промежуточных объектов в куче.

Raylib code without allocation:
Expand Collapse Copy
using Raylib_cs;
using System.Globalization;
using System.Numerics;
using System.Text.Unicode;

public unsafe class Program
{
    private static void Main(string[] args)
    {
        Raylib.SetConfigFlags(ConfigFlags.Msaa4xHint | ConfigFlags.ResizableWindow);
        Raylib.SetTargetFPS(75);
        Raylib.InitWindow(800, 480, "Hello World");

        int centerX = Raylib.GetScreenWidth() / 2;
        int centerY = Raylib.GetScreenHeight() / 2;

        var position = new Vector2(centerX, centerY);
        float speed = 500f;
        float radius = 36;

        Span<byte> positionTextBuffer = stackalloc byte[255];

        while (!Raylib.WindowShouldClose())
        {
            float dt = Raylib.GetFrameTime();

            Vector2 direction = Vector2.Zero;

            if (Raylib.IsKeyDown(KeyboardKey.W)) direction.Y -= 1;
            if (Raylib.IsKeyDown(KeyboardKey.S)) direction.Y += 1;
            if (Raylib.IsKeyDown(KeyboardKey.A)) direction.X -= 1;
            if (Raylib.IsKeyDown(KeyboardKey.D)) direction.X += 1;

            if (direction != Vector2.Zero)
            {
                direction = Vector2.Normalize(direction);

                position += direction * speed * dt;
            }

            var min = new Vector2(radius, radius);
            var max = new Vector2(Raylib.GetScreenWidth() - radius, Raylib.GetScreenHeight() - radius);

            position = Vector2.Clamp(position, min, max);

            Raylib.BeginDrawing();
            Raylib.ClearBackground(Color.Black);

            Raylib.DrawCircleV(position, radius, Color.White);

            if (Utf8.TryWrite(positionTextBuffer, CultureInfo.InvariantCulture,
                          $"FPS: {Raylib.GetFPS()} | Pos: {position.X:F0},{position.Y:F0}\0",
                          out _))
                
                fixed (byte* ptr = positionTextBuffer)
                    Raylib.DrawText((sbyte*)ptr, 10, 10, 30, Color.White);

            Raylib.EndDrawing();
        }

        Raylib.CloseWindow();
    }
}

Мы выделили память на стеке. Это бесплатно. Эта память исчезнет, как только метод завершится. Сборщик мусора даже не узнает, что здесь что-то происходило. В классическом подходе мы бы генерировали новую строку из System.String 75 раз в секунду. Это 4500 мусорных объектов в минуту только на вывод FPS. Это позор.

Utf8.TryWrite не создает строку. Компилятор разворачивает интерполяцию $"FPS:..." в последовательность вызовов AppendFormatted. Числа (FPS, координаты) конвертируются из float/int сразу в ASCII-байты в буфере positionTextBuffer. Нет промежуточного char[]. Нет конвертации UTF-16 -> UTF-8. Нет аллокаций. Мы фиксируем буфер (хотя для стека это формальность) и скармливаем указатель прямо в C-функцию Raylib.DrawText.

В итоге, в
этом цикле while происходит ноль аллокаций. GC.GetAllocatedBytesForCurrentThread() покажет абсолютный штиль.

НО! Не расслабляйтесь! Utf8.TryWrite не исправляет кривой код. Взгляните на кишки метода AppendFormatted<T>, который генерирует компилятор.


Метод AppendFormatted из TryWriteInterpolatedStringHandler:
Expand Collapse Copy
            /// <summary>Writes the specified value to the handler.</summary>
            /// <param name="value">The value to write.</param>
            /// <param name="format">The format string.</param>
            /// <typeparam name="T">The type of the value to write.</typeparam>
            public bool AppendFormatted<T>(T value, string? format)
            {
                // If there's a custom formatter, always use it.
                if (_hasCustomFormatter)
                {
                    return AppendCustomFormatter(value, format);
                }

                if (value is null)
                {
                    return true;
                }

                // Special-case enums to avoid boxing them.
                if (typeof(T).IsEnum)
                {
                    // TODO https://github.com/dotnet/runtime/issues/81500:
                    // Once Enum.TryFormat provides direct UTF8 support, use that here instead.
                    return AppendEnum(value, format);
                }

                // If the value can format itself directly into our buffer, do so.
                if (value is IUtf8SpanFormattable)
                {
                    if (((IUtf8SpanFormattable)value).TryFormat(_destination.Slice(_pos), out int bytesWritten, format, _provider))
                    {
                        _pos += bytesWritten;
                        return true;
                    }

                    return Fail();
                }

                string? s;
                if (value is IFormattable)
                {
                    // If the value can format itself directly into a UTF16 buffer, do so, then transcode.
                    if (value is ISpanFormattable)
                    {
                        return AppendSpanFormattable(value, format);
                    }

                    // If the value can ToString with the format / provider, get the resulting string, then append that.
                    s = ((IFormattable)value).ToString(format, _provider);
                }
                else
                {
                    // Fall back to a normal ToString and append that.
                    s = value.ToString();
                }

                return AppendFormatted(s.AsSpan());
            }

Если ваш тип (структура или класс) реализует IUtf8SpanFormattable (как это делают int, float, DateTime в .NET 8+), всё работает быстро. Байты пишутся сразу.

Если есть только ISpanFormattable, рантайм вынужден использовать промежуточный буфер для UTF-16, а затем транскодировать его в UTF-8. Это не аллоцирует строку в куче (обычно), но жрет такты на перекодировку.

Если ваш условный struct PlayerData не реализует ничего из вышеперечисленного, рантайм молча вызывает ToString().
 
Назад
Сверху Снизу