CpuUsageAsyncWatcher – сбор CpuUsage в любом async flow
От: VladCore  
Дата: 31.01.20 12:25
Оценка: 174 (4)
CpuUsageAsyncWatcher – сбор CpuUsage в любом асинхронном флоу.

https://github.com/devizer/Universe.CpuUsage/
Добавляем, будь ласка, дружно себе в избранное (на гитхабе) — чем больше лайков/звездочек наберет (на гитхабе) тем быстрее в https://github.com/dotnet/BenchmarkDotNet и в https://miniprofiler.com/dotnet/ появятся колонки с CpuUsage

Работает в Windows, Linux и MacOS. Минимальный Target: Net Core 1.0+, Net Framework 4.6+, Net Standard 1.3+. Несмотря на то что таски появились раньше NET 4.6, уведомления о переключении контекста (старт/стоп тасков) появилось только в 4.6. Отсюда минимум для NET Framework – 4.6

Минимальные требования к ОС — те же что и у класса CpuUsage: Windows 7/2008R2, Linux Kenrel 2.6.32+, Mac OS 10.9+

Автоматические покрытие тестами тоже что и у CpuUsage-класса: Windows Server 2016+, OSX 10.10+, Linux x64, Linux arm и 64 и 32 бита и Linux i386. Должно работать в Windows ARM, но не на чем тестить даже вручную. Потестить просто:
dotnet test -c Release –f netcoreapp2.2 или 3.0 или 3.1


Отдельно упомяну ручные тесты: Windows 7 x86, Linux ARMv5, FreeBSD mono (native) и FreeBSD .NET Core 2.0 (там .net core работает в так называемом linux compatibility layer и оно кому интересно устроено как WSL в Windows). Про FreeBSD ещё упомяну.

API

API очень крутой, потому что очень простой: Единственное cвойство, оно thread-safe, это CpuUsageAsyncWatcher.Totals. Оно возвращает коллекцию ICollection<ContextSwitchMetrics> «пойманных» context switch-ей с метриками всего дерева тасков. Метрики по каждому переключению контекста всех sub-тасков две пока что:
public class ContextSwitchMetrics
{
    public double Duration { get; internal set; }   // это не только для красоты
    public CpuUsage CpuUsage { get; internal set; } // тут отдельно User usage & Kernel usage
}

В отличии от Stopwatch, не поддерживаются Resume и Restart – CPU Usage собирается во всех sub-тасках, sub-sub тасках и т.д., которые обычно каждый в своем потоке – в них не вклинишся.

Для примера – middleware которое собирает CPU Usage в ASP.NET Core в каждом http-запросе.
public void Configure(IApplicationBuilder app)
{
    app.Use(async (context, next) =>
    {
        CpuUsageAsyncWatcher watcher = new CpuUsageAsyncWatcher();
        await next.Invoke();
        Console.WriteLine($"Cpu Usage by http request is {watcher.Totals.GetSummaryCpuUsage()}");
    });
}


Дальше можно было бы не читать, много кода с тестами, хоть и элементарного.

4 теста


Тестов CpuUsageAsyncWatcher всего 4. Жду советов какие тесты добавить. Все 4 теста запускаются с 4мя (на Windows) или 2мя (на Linux & MacOS) разными TaskScheduler-ами:
— TaskScheduler.Default, он же ThreadPoolTaskScheduler.
Плюс три шедулера из ParallelExtensionsExtras (была такая древняя рокет-сайнс разработка у MS)
— ThreadPerTaskScheduler (без коментариев).
— QueuedTaskScheduler без ограничений потоков (у них оно называется Concurrency Limits).
— QueuedTaskScheduler с лимитом в 1 конкурентный поток.

QueuedTaskScheduler работает только в Windows — тесты на windows запускаются со всеми 4мя TaskScheduler-ами. На Линукс и Mac OS — только первые два.

Ассерт-ы в тестах очень простые и понятные. Проверяется что 1) в коллекции CpuUsageAsyncWatcher.Totals присутствует нужное количество context switches и 2) суммарный CPU Usage по всем-всем "потокам" равен ожидаемому с заданной точностью.

Я приведу все тесты, потому что 100%-е покрытие получается не очевидным
Тест-1. SimpleTests — последовательно стартуем несколько тасков и считаем CPU Usage одним CpuUsageAsyncWatcher экземпляром.
[Test] public async Task SimpleTests(AsyncSchedulerCase testEnvironment)
{
    // Act (durations are for debugging)
    CpuUsageAsyncWatcher watch = new CpuUsageAsyncWatcher();
    await testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 200));
    await testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 500));
    await testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 800));
    var totals = watch.Totals;
    
    // Assert
    long actualMicroseconds = totals.GetSummaryCpuUsage().TotalMicroSeconds;
    long expectedMicroseconds = 1000L * (200 + 500 + 800);
    Console.WriteLine($"Expected usage: {(expectedMicroseconds/1000d):n3}, Actual usage: {(actualMicroseconds/1000d):n3} milliseconds");            
    Console.WriteLine(watch.ToHumanString(taskDescription:"SimpleTests()"));
    
    Assert.GreaterOrEqual(totals.Count, 6, "Number of context switches should be 6 at least");
    Assert.AreEqual(expectedMicroseconds, actualMicroseconds, 0.1d * expectedMicroseconds, "Actual CPU Usage should be about as expected.");
}


Тест-2. ParallelTests — параллельно запускаем несколько тасков и тоже считаем CPU Usage одним CpuUsageAsyncWatcher экземпляром.
[Test] public async Task ParallelTests(AsyncSchedulerCase testEnvironment)
{
    // Act (durations are for debugging)
    CpuUsageAsyncWatcher watcher = new CpuUsageAsyncWatcher();
    TaskFactory tf = new TaskFactory();
    var task4 = testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 2400));
    var task3 = testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 2100));
    var task2 = testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 1800));
    var task1 = testEnvironment.Factory.StartNew(() => LoadCpu(milliseconds: 1500));
    await Task.WhenAll(task1, task2, task3, task4);
    var totals = watcher.Totals;
    
    // Assert
    long actualMicroseconds = totals.GetSummaryCpuUsage().TotalMicroSeconds;
    long expectedMicroseconds = 1000L * (2400 + 2100 + 1800 + 1500);
    Console.WriteLine($"Expected usage: {(expectedMicroseconds/1000d):n3}, Actual usage: {(actualMicroseconds/1000d):n3} milliseconds");
    Console.WriteLine(totals.ToHumanString(taskDescription:"ParallelTests()"));
    Assert.GreaterOrEqual(totals.Count, 6, "Number of context switches should be 6 at least");
    Assert.AreEqual(expectedMicroseconds, actualMicroseconds, 0.1d * expectedMicroseconds, "Actual CPU Usage should be about as expected."); 
}


Тест-3. AwaitForEachTests — тоже самое что и предыдущий тест но с модным await foreach
[Test] public async Task AwaitForEachTests(AsyncSchedulerCase testEnvironment)
{
    // Act (durations are for debugging)
    CpuUsageAsyncWatcher watcher = new CpuUsageAsyncWatcher();
    long expectedMicroseconds = 0;
    await foreach (var milliseconds in GetLoadings())
    {
        await testEnvironment.Factory.StartNew(() => CpuLoader.Run(minDuration: milliseconds, minCpuUsage: milliseconds, needKernelLoad: true));
        expectedMicroseconds += milliseconds * 1000L;
    }

    var totals = watcher.Totals;
    long actualMicroseconds = totals.GetSummaryCpuUsage().TotalMicroSeconds;
    Console.WriteLine($"Expected usage: {(expectedMicroseconds/1000d):n3}, Actual usage: {(actualMicroseconds/1000d):n3} milliseconds");
    Console.WriteLine(totals.ToHumanString(taskDescription:"AwaitForEachTests()"));
    
    // Assert, вместо 8 надо бы писать new[] {250, 450, 650, 850}.Length * 2
    Assert.GreaterOrEqual(totals.Count, 8, "Number of context switches should be 8 at least");
    Assert.AreEqual(expectedMicroseconds, actualMicroseconds, 0.1d * expectedMicroseconds, "Actual CPU Usage should be about as expected."); 
}
static async IAsyncEnumerable<int> GetLoadings([EnumeratorCancellation] CancellationToken token = default)
{
    foreach (var ret in new[] {250, 450, 650, 850})
    {
        yield return ret;
        await Task.Delay(10);
    }
}



Тест-4. ConcurrentTest — параллельно запускаем несколько не тасков, а CpuUsageAsyncWatcher. У каждого CpuUsageAsyncWatcher свои таски. Все экземпляры друг другу не мешают и не должны. Я его написал потому что исходники уведомлений об Context Switch-ах не очень скажем так прозрачные для понимания. Может он и не нужен. Ну и здесь мы даем "прикурить" — запускаем ProcessorCount + 9 параллельных cpu-bound тасков.
[Test] public async Task ConcurrentTest(AsyncSchedulerCase testEnvironment)
{
    IList<Task> tasks = new List<Task>();
    int errors = 0;
    int maxThreads = Environment.ProcessorCount + 9;
    // hypothesis: each thread load should be exponential?
    for (int i = 1; i <= maxThreads; i++)
    {
        var iCopy = i;
        tasks.Add( testEnvironment.Factory.StartNew(async () =>
        {
            var expectedMilliseconds = iCopy * 400;
            if (!await CheckExpectedCpuUsage(expectedMilliseconds, testEnvironment.Factory)) 
                Interlocked.Increment(ref errors);
            
        }, TaskCreationOptions.LongRunning).Unwrap());
    }

    await Task.WhenAll(tasks);
    Assert.IsTrue(errors == 0, "Concurrent CpuUsageAsyncWatchers should not infer on each other. See details above");
}
async Task<bool> CheckExpectedCpuUsage(int expectedMilliseconds, TaskFactory factory)
{
    // Act
    CpuUsageAsyncWatcher watcher = new CpuUsageAsyncWatcher();
    await factory.StartNew(() => LoadCpu(expectedMilliseconds));
    Console.WriteLine(watcher.ToHumanString(taskDescription: $"'Expected CPU Load is {expectedMilliseconds} milli-seconds'"));
            
    Assert.AreEqual(2, watcher.Totals.Count, "The CheckExpectedCpuUsage should produce exact 2 context switches");
    // Assert: CpuUsage should be expectedMilliseconds
    var actualSeconds = watcher.GetSummaryCpuUsage().TotalMicroSeconds / 1000000d;
    bool isOk = actualSeconds >= expectedMilliseconds / 1000d;
    return isOk;
}


Во всех тестах вызывает LoadCpu(milliseconds) — он висит в текущем потоке и грузит CPU на заданное количество миллисекунд. т.е. аргумент milliseconds — это не duration а CPU Usage.

Теперь собственно вопрос — какая бы тесты еще дописать. Какие выкинуть?
Есть ли соврменные реализации TaskScheduler-ов? QueuedTaskScheduler из ParallelExtensionsExtras очень крутая штука для Legacy/монолитного дизайна, но ее так в Core и не переписали. Или я плохо искал.

P.S. Пока что штука экспериментальная. Задница в том что последний Context Switch на некоторых рантаймах/железе не попадает в CpuUsageAsyncWatcher.Totals. Что значит на некоторых? Например на моем старом i7 4C/8Т и на ARM все ok. А на 2х ядерном xeon на билд серверах последний Context Switch теряется. тупо callback не вызывается. Повторюсь — не на всех 4х тестах плюс воспроизводится 100%. Куда копать пока не знаю. Именно поетому в тестах там стоит ProcessorCount + 9. Фактчески почти во всех 4х тестах CpuUsageAsyncWatcher юзаю await Task.Delay(1) что бы терялся НЕнужный context switch. В посте я его везде вырезал для наглядности.

И еще один момент про FreeBSD. Не зря я не поленился и запустил тесты на ней. Поскольку таски могут быть микротасками и ЧАСТО так и ЕСТЬ (напомню что латенси у await — одна МИКРО-секунда в .net core 3.*), то я вобщем еще добавил гистограммы ТОЧНОСТИ CPU Usage. Она разная в зависимости от ОС и версии ядра в Linux. Уже догадались наверно что самая высокая точность — во FreeBSD. хуже в Mac OS, но тоже в МИКРО-секундах.
А вот в Линукс и Windows — точность в МИЛЛИ-секундах. Так что если нужна МИКРОСЕКУНДНАЯ ТОЧНОСТЬ — то поднимайте билд сервер на FreeBSD. Шучу Но по факту
только на FreeBSD точность совпадает performance — менее 2х МИКРОСЕКУНД — на каждый запрос CpuUsage.Get() ядро присылает обновленное значение cpu usage.

Жду фидбек. В основном по логике тестов.
Отредактировано 02.02.2020 4:55 VladCore . Предыдущая версия . Еще …
Отредактировано 02.02.2020 4:54 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 18:13 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 13:04 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 13:01 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:58 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:37 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:36 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:33 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:32 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:31 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:28 VladCore . Предыдущая версия .
Отредактировано 31.01.2020 12:27 VladCore . Предыдущая версия .
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.