Оптимизация небрежно написанного маппера

в 17:30, , рубрики: .net, C#, performance optimization

О чём это я

Этот проект является реальным случаем из моей работы и посвящён последствиям небрежного написания простого маппера в рамках исправления одного эндпойнта (конечной точки / «ручки») в Web Rest API сервисе в рамках проекта по рефакторингу и переезду с собственных серверов в большие облака.

Поскольку код этого проекта нельзя распространять, я создал очень похожий маппер для прогнозов погоды (навеянный стандартным шаблоном).
Полный код доступен на GitHub.

Как все начиналось

В один день тестировщик пришла к программистам с вопросами об эндоинте, который работает "вечно". Сие утверждение было странным, потому как ранее его не замечали, но было одно НО. Пару дней до этого случилось долгожданное событие - нам предоставили полноценный бекап тестовой базы, который конечно же мы быстренько залили на наши тестовые энвы вместо нагенерированных данных. В следующие несколько дней было обнаружено ещё с десяток "вечно" работающих эндпоинтов, но в этой статье будет исправление только одного из них, который работал около 30 секунд на тестовом маломощном окружении и около 16 секунд на моем ПК. После прочтения цепочки вызовов и недолгой отладки я обнаружил, что маппер занимает всё это время, за исключением ~100 мс, то есть 16 секунд тратиться на создание новой коллекции довольно простых объектов, но непростых исходных коллекций – в большинстве случаев содержащих от 5000 до 25000 записей.

Для чего нужен этот маппер

Цель маппера - объединить данные из двух массивов получаемых на вход: Temperature[] Temperatures и string[] Places в третий массив и выдать его обратно. Результирующий массив должен быть того же размера, что и массив Temperatures, и содержать значения и даты из массива Temperatures. Данные для полей States и Seasons должны быть получены из массива Places. Каждая дата в массиве Temperatures уникальна. Массив Places может быть меньше, равен или больше массива Temperatures. Массив Places содержит строки, которые могут состоять из одного, двух или трех сегментов с разделителем ';'. Первый сегмент — это дата в формате: месяц/день/год и постоянная временная метка 00:00:00, дата уникальна для каждого массива; этот сегмент присутствует всегда. Второй сегмент - две буквы штата, опциональный и может отсутствовать. Третий сегмент — это аббревиатура штата, так же опциональный и может отсутствовать вместе с разделителем.  Аббревиатуры штатов и сезонов должны быть преобразованы в полные названия штатов и сезонов, маппер должен быть нечувствительны к регистру. Массивы должны сопоставляться по дате. Если в массиве Places нет записи с соответствующей датой, или запись не имеет второго и/или третьего сегмента, поля states и/или seasons должны быть null.

Результаты

BenchmarkDotNet v0.13.11, Windows 11, AMD Ryzen 7 6800H.
.NET SDK 8.0.204
[Host] : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2

Method

N

Mean

Error

StdDev

Median

Ratio

RatioSD

Gen0

Gen1

Gen2

Allocated

Alloc Ratio

MapOriginal

10000

13,117.831ms

120.0636ms

112.3075ms

13,117.128ms

1,241.72

58.01

1568000.0000

25000.0000

-

12511.64MB

2,437.84

MapOptimized

10000

12.582ms

0.5073ms

1.4957ms

13.328ms

1.00

0.00

875.0000

796.8750

484.3750

5.13MB

1.00

MapOptimizedStruct

10000

5.494ms

0.1072ms

0.1002ms

5.515ms

0.52

0.02

570.3125

515.6250

390.6250

4.16MB

0.81

MapOptimizedStructMarshal

10000

5.175ms

0.0451ms

0.0400ms

5.183ms

0.49

0.02

632.8125

585.9375

468.7500

3.39MB

0.66

Почему маппер такой медленный?

Точкой входа является небольшой метод Map.
Основная логика находится в методе MapSingle, где мы можем найти главного расточителя ресурсов.

public static class Original
{
    private static readonly DateTimeFormatInfo DateTimeFormatInfo = new()
    {
        ShortDatePattern = "MM/dd/yyyy HH:mm:ss",
        LongDatePattern = "MM/dd/yyyy HH:mm:ss",
    };
    
    // The "entry point"
    public static Output[] Map(string json)
    {
        var data = JsonSerializer.Deserialize<Input>(json)!;
        return data.Temperatures.Select(t => t.MapSingle(data.Places)).ToArray();
    }

    // The main mapper
    private static Output MapSingle(this Temperature src, string[] places)
    {
        var data = GetData(src, places);
        var state = data?.Length > 1 ? data[1] : null;
        var season = data?.Length > 2 ? data[2].ToLower() : null;

        var mapSingle = new Output
        {
            Date = src.Date,
            Value = double.Parse(src.Value.ToString("0.##0")),
            State = GetState(state),
            Season = GetSeason(season)
        };
        return mapSingle;

        string[]? GetData(Temperature temperature, string[] strings)
        {
            // After some time reading the code, you can notice that this is a foreach cycle inside .Select cycle
            // for searching an element and this is the general computation recourse waster.
            foreach (var str in strings)
            {
                // The method creates a lot of allocations with new strings, but only the fist one is in use.
                var segments = str.Split(';');
                
                // To pass tests, paste the DateTimeFormatInfo as second argument here in DateTime.TryParse().  
                if (DateTime.TryParse(segments[0], out var result))
                {
                    if (DateTime.Equals(temperature.Date, result))
                    {
                        return segments;
                    }
                }
            }

            return null;
        }

        string? GetState(string? s1)
        {
            return s1 switch
            {
                "WA" => "Washington",
                "OR" => "Oregon",
                "NE" => "New York",
                "AL" => "Alaska",
                "CO" => "Colorado",
                _ => null
            };
        }

        string? GetSeason(string? s2)
        {
            return s2 switch
            {
                "wi" => "Winter",
                "sp" => "Spring",
                "su" => "Summer",
                "fall" => "Autumn",
                _ => null
            };
        }
    }
}

Как улучшить

Мы можем значительно повысить производительность, просто заменив линейный поиск в массиве/списке на поиск по Dictionary, а точнее, FrozenDictionary. Это оптимизированный для поиска неизменяемый словарь, представленный в .NET 8.
Но стоит ли ограничиваться только этими изменениями, ведь код всё ещё далёк от приличного состояния?
Например, использование метода .Split() создает новые строки, которые будут проверяться сборщиком мусора. Этот метод можно заменить на .AsSpan() без создания объектов на куче.
Эти изменения не приведут к кардинальному повышению производительности конкретного метода, зато улучшат общую производительность сервиса за счет снижения нагрузки на GC.
После этих улучшений время выполнения метода резко сократится.

public static class Optimized
{
    private static readonly DateTimeFormatInfo DateTimeFormatInfo = new()
    {
        ShortDatePattern = "MM/dd/yyyy",
        LongDatePattern = "MM/dd/yyyy"
    };

    public static Output[] Map(string json)
    {
        var data = JsonSerializer.Deserialize<Input>(json)!;
        
        // The biggest performance changes are here - List has been replaced with FrozenDictionary. 
        var places = data.Places.ToFrozenDictionary(GetDate, GetSeasonState);

        return data.Temperatures.Select(t =>
        {
            places.TryGetValue(DateOnly.FromDateTime(t.Date), out var result);
            return new Output
            {
                Date = t.Date,
                Value = double.Round(t.Value, 3),
                Season = result.Season,
                State = result.State
            };
        }).ToArray();
    }

    private static DateOnly GetDate(string s)
    {
        return DateOnly.Parse(s.AsSpan(0, 10), DateTimeFormatInfo);
    }

    private static (string? Season, string? State) GetSeasonState(string s)
    {
        return (GetSeason(s), GetState(s));

        string? GetState(string str)
        {
            if (str.Length < 21)
                return null;

            // The contract is solid - we can use span with hardcoded values to use a part of the string.
            // It eliminates all heap allocations because span is a ref struct.
            // It will not dramatically improve the performance of the specific method by improving the overall performance of the service.
            var state = str.AsSpan(20, 2);
            return state switch
            {
                { } when state.Equals("WA", StringComparison.OrdinalIgnoreCase) => "Washington",
                { } when state.Equals("OR", StringComparison.OrdinalIgnoreCase) => "Oregon",
                { } when state.Equals("CO", StringComparison.OrdinalIgnoreCase) => "Colorado",
                { } when state.Equals("AL", StringComparison.OrdinalIgnoreCase) => "Alaska",
                { } when state.Equals("NE", StringComparison.OrdinalIgnoreCase) => "New York",
                _ => null
            };
        }

        string? GetSeason(string str)
        {
            if (str.Length < 24)
                return null;

            var season = str.AsSpan()[23..];
            return season switch
            {
                { } when season.Equals("wi", StringComparison.OrdinalIgnoreCase) => "Winter",
                { } when season.Equals("sp", StringComparison.OrdinalIgnoreCase) => "Spring",
                { } when season.Equals("su", StringComparison.OrdinalIgnoreCase) => "Summer",
                { } when season.Equals("fall", StringComparison.OrdinalIgnoreCase) => "Autumn",
                _ => null
            };
        }
    }
}

Добавляем ускорение, но увеличиваем сложность.

Мы можем сделать маппер еще быстрее, не погружаясь в небезопасный код и не превращая маппер readonly для большинства программистов.
Мы можем заменить классы на структуры для анемичных моделей, а если быть точным, то использовать readonly record struct вместо records.

// Before
public record Output
{
    public DateTime Date { get; init; }
    public double Value { get; init; }
    public string? State { get; init; }
    public string? Season { get; init; }
}

public record Input(Temperature[] Temperatures, string[] Places);

public record Temperature(DateTime Date, double Value);

// After
public readonly record struct OutputSt
{
    public DateTime Date { get; init; }
    public double Value { get; init; }
    public string? State { get; init; }
    public string? Season { get; init; }
}

public readonly record struct InputSt(TemperatureSt[] Temperatures, string[] Places);

public readonly record struct TemperatureSt(DateTime Date, double Value);

Использование struct не такое простое, как использование классов, из-за иной природы. Мы должны помнить, что при передаче структуру в качестве аргумента или возвращении её из метода, структура копируются.
Для небольших структур, таких как примитивы, это более чем нормально, но для больших сущностей это приведет к снижению производительности.
Но мы можем устранить эту проблему, просто используя ref / it / out, чтобы избежать копирования данных.
Еще одной особенностью структур является структура массивов (и коллекций на основе массивов).
Массив классов хранит только ссылки на экземпляр этого класса в куче, а массив структур хранит всю структуру целиком.
Я думаю, что массив структур дает меньшую фрагментацию памяти (но имеет больше шансов быть выделенным в LOH), что уменьшит время получения элемента массива.
Эти небольшие изменения улучшают время выполнения метода в два раза.

public static class OptimizedStruct
{
    private static readonly DateTimeFormatInfo DateTimeFormatInfo = new()
    {
        ShortDatePattern = "MM/dd/yyyy",
        LongDatePattern = "MM/dd/yyyy"
    };
    
    public static OutputSt[] Map(string json)
    {
        var data = JsonSerializer.Deserialize<InputSt>(json);
        var places = data.Places.ToFrozenDictionary(GetDate, GetSeasonState);

        return data.Temperatures.Select(t =>
        {
            places.TryGetValue(DateOnly.FromDateTime(t.Date), out var result);
            return new OutputSt
            {
                Date = t.Date,
                Value = double.Round(t.Value, 3),
                Season = result.Season,
                State = result.State
            };
        }).ToArray();
    }

    private static DateOnly GetDate(string s)
    {
        return DateOnly.Parse(s.AsSpan(0, 10), DateTimeFormatInfo);
    }

    private static (string? Season, string? State) GetSeasonState(string s)
    {
        return (GetSeason(s), GetState(s));
        
        string? GetState(string str)
        {
            if (str.Length < 21)
                return null;

            var state = str.AsSpan(20, 2);
            return state switch
            {
                { } when state.Equals("WA", StringComparison.OrdinalIgnoreCase) => "Washington",
                { } when state.Equals("OR", StringComparison.OrdinalIgnoreCase) => "Oregon",
                { } when state.Equals("CO", StringComparison.OrdinalIgnoreCase) => "Colorado",
                { } when state.Equals("AL", StringComparison.OrdinalIgnoreCase) => "Alaska",
                { } when state.Equals("NE", StringComparison.OrdinalIgnoreCase) => "New York",
                _ => null
            };
        }

        string? GetSeason(string str)
        {
            if (str.Length < 24)
                return null;

            var season = str.AsSpan(23, str.Length - 23);
            return season switch
            {
                { } when season.Equals("wi", StringComparison.OrdinalIgnoreCase) => "Winter",
                { } when season.Equals("sp", StringComparison.OrdinalIgnoreCase) => "Spring",
                { } when season.Equals("su", StringComparison.OrdinalIgnoreCase) => "Summer",
                { } when season.Equals("fall", StringComparison.OrdinalIgnoreCase) => "Autumn",
                _ => null
            };
        }
    }
}

Ещё чуток ускорения.

Мы можем немного повысить производительность, используя специальный метод GetValueRefOrNullRef из статического класса CollectionsMarshal для поиска элемента и получения ссылки на него.
При этом FrozenDictionary придется заменить на обычный Dictionary.
Эта манипуляция дает нам примерно 6 % прироста по сравнению с предыдущим методом.

public static class OptimizedStructMarshal
{
    private static readonly DateTimeFormatInfo DateTimeFormatInfo = new()
    {
        ShortDatePattern = "MM/dd/yyyy",
        LongDatePattern = "MM/dd/yyyy"
    };
    
    public static OutputSt[] Map(string json)
    {
        var data = JsonSerializer.Deserialize<InputSt>(json);
        var places = data.Places.ToDictionary(GetDate, GetSeasonState);

        return data.Temperatures.Select(t =>
        {
            // New way to find the element.
            ref var result = ref CollectionsMarshal.GetValueRefOrNullRef(places, DateOnly.FromDateTime(t.Date));
            return new OutputSt
            {
                Date = t.Date,
                Value = double.Round(t.Value, 3),
                Season = result.Season,
                State = result.State
            };
        }).ToArray();
    }
    
    private static DateOnly GetDate(string s)
    {
        return DateOnly.Parse(s.AsSpan(0, 10), DateTimeFormatInfo);
    }

    private static (string? Season, string? State) GetSeasonState(string s)
    {
        return (GetSeason(s), GetState(s));
        
        string? GetState(string str)
        {
            if (str.Length < 21)
                return null;

            var state = str.AsSpan(20, 2);
            return state switch
            {
                { } when state.Equals("WA", StringComparison.OrdinalIgnoreCase) => "Washington",
                { } when state.Equals("OR", StringComparison.OrdinalIgnoreCase) => "Oregon",
                { } when state.Equals("CO", StringComparison.OrdinalIgnoreCase) => "Colorado",
                { } when state.Equals("AL", StringComparison.OrdinalIgnoreCase) => "Alaska",
                { } when state.Equals("NE", StringComparison.OrdinalIgnoreCase) => "New York",
                _ => null
            };
        }

        string? GetSeason(string str)
        {
            if (str.Length < 24)
                return null;

            var season = str.AsSpan(23, str.Length - 23);
            return season switch
            {
                { } when season.Equals("wi", StringComparison.OrdinalIgnoreCase) => "Winter",
                { } when season.Equals("sp", StringComparison.OrdinalIgnoreCase) => "Spring",
                { } when season.Equals("su", StringComparison.OrdinalIgnoreCase) => "Summer",
                { } when season.Equals("fall", StringComparison.OrdinalIgnoreCase) => "Autumn",
                _ => null
            };
        }
    }
}

PS: Статья написана для популяризации своего профиля на LinkedIn. Код доступен на GitHub.
PSS: Спасибо DeepL за обратный перевод моего же текста на русский язык).

Автор: Vanirn

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js