Производительность RemoteFX, часть 2

В первой части исследования мы оценивали эффективность включения GPU-ускорения для RemoteFX в однопользовательском режиме. Это второй этап, в котором займёмся проверкой и оценкой производительности для нескольких одновременных терминальных сеансов, чтобы понять границы масштабируемости облачного сервера с выделенной видеокартой.

Содержание
  • Введение

  • Конфигурация тестовой среды

  • Методика тестирования

  • Обработка данных

  • Анализ результатов и наиболее интересные графики

    • Задержки обработки пользовательского ввода и нагрузка на процессор

    • Частота кадров

    • Использование оперативной памяти

    • Нагрузка на видеокарту

    • Общие сетевые метрики

  • Заключение

  • Приложения

    • Set-DataCollectors.ps1

    • Set-DataCollectors.cmd

    • Start-SyncedTest.ps1

    • Start-SyncedTest.cmd

    • helper.psm1

    • blg2csv.ps1

    • blg2csv.cmd

    • figures_alt.ipynb

    • figures.ipynb

    • Диагностические счётчики

    • UDP трафик

    • TCP трафик

Утилизация ресурсов vCPU в режиме одного пользователя для первых двух тестов составляла в среднем 40–50%. Поэтому, предварительные замеры для двух и трёх сессий были выполнены без изменения конфигурации виртуального сервера. Однако оказалось, что узким местом масштабирования, в первую очередь, являются именно ресурсы vCPU и vRAM: общая нагрузка стала так высока, что монитору производительности перестало хватать ресурсов для фиксации показаний счётчиков и в замерах стали появляться значительные пробелы.

21557f889dbe5a931a0c27a40162d521.png

Поэтому конфигурация виртуальной машины была изменена: были добавлены ядра и оперативная память.

Конфигурация тестовой среды

Сервер

  • 2 → 6 vCPU Intel® Xeon® CPU E5–2696 v4 @ 2.20GHz

  • 8 → 12 GB RAM

  • GPU NVIDIA GRID M60–1Q, Dedicated Memory 929 MB, Shared Memory 4095 → 6143 MB

  • гостевая ОС Windows Server 2019 Standart x64 1809 (Version 10.0.17763.1577), DirectX 12

  • network in/out rate limit 50 Mbps

Языком интерфейса по умолчанию был сделан английский: для решения «белых пятен» в замерах вместо сбора данных через PerfMon были попытки использовать командлет Get-Counter с Real Time приоритетом процесса. А русские названия счётчиков памяти усложняли эти попытки.

Дело в том, что когда вы определяете новый сборщик данных PerfMon через GUI, то видите псевдонимы счётчиков, которые зависят от языка интерфейса. Но фактически, при работе, используются «родные» английские имена. Это можно увидеть в xml файле, если сохранить сборщик как шаблон. Утилита logman тоже работает с английскими названиями.

Командлет Get-Counter работает именно с псевдонимами. То есть, от языка интерфейса зависит, какая из двух команд завершится ошибкой:

PS C:\> (Get-Counter -ListSet 'RemoteFX Network').paths
PS C:\> (Get-Counter -ListSet 'Сеть RemoteFX').paths

Принципиально такой подход не решил проблему, пробелы остались, хотя и в меньшем объёме. Но стало проще конфигурировать сбор данных в PerfMon / PowerShell, а, после разделения счётчиков на две группы, наконец-таки заработали счётчики оперативной памяти!

Методика тестирования

Не претерпела существенных изменений. Проводились те же самые три теста:

  • ввод текста + 3D BenchMark

  • ввод текста + просмотр локальных видеофайлов

  • ввод текста + просмотр youtube-ролика

Отличие от первой части заключалось в том, что серия тестов повторялась сначала для одной, затем для двух и для трёх одновременных терминальных сессий. А набор счётчиков из первой части был разделён на две группы:

Метрики
  • Общие метрики:

    • '\Memory\% Committed Bytes In Use'

    • '\Memory\Available Bytes'

    • '\Processor Information (_Total)\% Processor Time'

    • '\NVIDIA GPU (*)\% GPU Usage'

    • '\NVIDIA GPU (*)\% GPU Memory Usage'

    • '\NVIDIA GPU (*)\% FB Usage'

    • '\NVIDIA GPU (*)\% Video Decoder Usage'

    • '\NVIDIA GPU (*)\% Video Encoder Usage'

  • Метрики сеансов:

    • '\User Input Delay per Session (#id)\Max Input Delay' # задержка в указанном сеансе

    • '\RemoteFX Network (RDP-Tcp#N)\Loss Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\Current TCP Bandwidth'

    • '\RemoteFX Network (RDP-Tcp#N)\Current UDP Bandwidth'

    • '\RemoteFX Network (RDP-Tcp#N)\Total Sent Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\TCP Sent Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\UDP Sent Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\Total Received Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\TCP Received Rate'

    • '\RemoteFX Network (RDP-Tcp#N)\UDP Received Rate'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Input Frames/Second'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Output Frames/Second'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Frame Quality'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Average Encoding Time'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Graphics Compression ratio'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Frames Skipped/Second — Insufficient Server Resources'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Frames Skipped/Second — Insufficient Network Resources'

    • '\RemoteFX Graphics (RDP-Tcp#N)\Frames Skipped/Second — Insufficient Client Resources'

Общие метрики не зависели от количества активных сеансов, счётчиков всегда было восемь в любом тесте для любого количества пользователей. А вот метрики сеаснов добавлялись для каждого пользователя отдельно, так как учитывали только свою терминальную сессию.

Чтобы не заниматься ручной перенастройкой PerfMon при входе очередного пользователя на терминальный сервер, были написаны скрипты, которые автоматически настраивали группы сборщиков данных в зависимости от текущих терминальных сессий.

Замеры и тесты тоже запускались скриптами — для удобства и синхронизации: пользователям достаточно было запустить сценарий и указать нужный тест, который стартовал в начале каждой новой минуты у всех одновременно. Спустя несколько секунд этот же скрипт в сессии администратора запускал сбор данных.

Большинство счётчиков сеанса использует номер терминального сеанса, а счётчик задержек — id. Поэтому, по окончании теста в журнал тестирования matches.csv записывались данные текущего теста: пользователи, id и номера сессий, имена файлов с замерами:

"test #3 WMPlayer users 3 general.blg","2021.02.09 12:00:12","administrator","rdp-tcp#8","2","+03"
"test #3 WMPlayer users 3 session.blg","2021.02.09 12:00:12","administrator","rdp-tcp#8","2","+03"
"test #3 WMPlayer users 3 general.blg","2021.02.09 12:00:12","2","rdp-tcp#12","3","+03"
"test #3 WMPlayer users 3 session.blg","2021.02.09 12:00:12","2","rdp-tcp#12","3","+03"
"test #3 WMPlayer users 3 general.blg","2021.02.09 12:00:12","3","rdp-tcp#66","4","+03"
"test #3 WMPlayer users 3 session.blg","2021.02.09 12:00:12","3","rdp-tcp#66","4","+03"

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

Исходный код скриптов доступен в приложении:

  • (пере-)установка сборщиков данных в PerfMon — Set-DataCollectors.ps1 и Set-DataCollectors.cmd

  • Одновременный запуск тестов и замеров — Start-SyncedTest.ps1 и Start-SyncedTest.cmd

  • Вспомогательный код был вынесен в отдельный файл модуля PowerShell — helper.psm1

Обработка данных

Скрипт конвертации двоичных файлов в формат csv из первой части также был модифицирован: теперь он учитывал наличие данных по нескольким сессиям и исправлял заголовки с помощью файла matches.csv.

  • Исходный код скриптов обработки двоичных файлов с результатами замеров можно посмотреть в приложении — blg2csv.ps1 и blg2csv.cmd

Обработка данных и построение графиков, как и раньше, выполнена в Jupiter-блокнотах с помощью библиотек pandas и matplotlib.

Теперь на одну метрику приходилось уже девять графиков вместо трёх, поэтому, для наглядности, графики располагались на диаграмме двумя способами:

          test2   test3   test4                  1 user  2 users 3 users
        -------------------------               -------------------------
1 user  |       |       |       |       test2   |       |       |       |
        -------------------------               -------------------------
2 users |       |       |       |       test3   |       |       |       |
        -------------------------               -------------------------
3 users |       |       |       |       test4   |       |       |       |
        -------------------------               -------------------------
  • Исходный код обоих блокнотов есть в приложении — figures.ipynb и figures_alt.ipynb

Анализ результатов и наиболее интересные графики

Задержки обработки пользовательского ввода и нагрузка на процессор

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

bba5cd76adc16f768ad20ef2b2ad382b.png

Интересен он ещё тем, что в тесте '3D BenchMark' этот показатель получился аномально высоким: задержка измерялась секундами для двух одновременных сеансов и десятками секунд в случае трёх сеансов! В норме показатель измеряется в миллисекундах.

23658278241f8c99d39ed428c30f4351.png

Диаграмма с таким расположением графиков приведена для лучшей демонстрации аномалии. Последующие диаграммы будут с другим расположением графиков, «тесты в ряд», для более наглядной демонстрации и сравнения изменений показателей при изменении количества сеансов.

Вот так те же самые данные будут выглядеть при таком расположении:

41f9d6c44b7c68b8d926d7e3528b5085.png

В официальной документации по диагностике проблем производительности есть пример, когда задержка ввода увеличивается из-за возрастающей нагрузки на процессор при подключении новых пользователей. В нашем случае это не объясняет увеличение задержки ввода на три-четыре порядка: счётчик загрузки процессора находится в пределах 60% и даже снизился в случае трёх сеансов. Другие метрики, в том числе и сетевые, тоже остались в норме.

3f8cf0f9d874933c02f3b8e5f924b473.png5ce35b824fa5134070f8a91bff325e32.png

А утилизация виртуального процессора оказалась выше.

bade4442fce6e708e59d87fb43c13e9e.png

Частота кадров

В 3D тесте частота кадров ожидаемо зависела от количества пользователей, запустивших бенчмарк.

Два других теста показали хорошую производительность для двух терминальных сессий, а в случае трёх сеансов уже не хватало производительности процессора.

В первой части исследования при проигрывании ролика «Flying Through Forest 1» мы видели провалы до 20–15 fps, но тогда виртуальная машина использовала только два ядра. После увеличения vCPU до 6 ядер показатель fps явно стал лучше для одного-двух сеансов и только для трёх сеансов он снова просел.

Youtube-тест также показывал отличные результаты, пока не подключился третий пользователь, что вызвало почти 100% нагрузку на процессор, а fps при этом просел на 10 кадров.

5fc543ce47568483453ccbc7eddc2448.png8c84ca9af73bb7b817dff12701698b3c.png

Использование оперативной памяти

Счётчики показали вполне ожидаемый результат: с ростом числа сеансов свободной памяти становилось меньше, а использовалась она чаще. В среднем, один тест «стоил» одному пользователю около 1 ГБ.

7043b212122ed37635354950239188e8.pngffadf9778edd60de8395a43c1b8966a2.png

Нагрузка на видеокарту

Здесь также замеры не показали ничего необычного: самым тяжёлым для процессора/памяти видеокарты был 3D тест. Больше пользователей → меньше кадров в секунду → ниже использование фреймбуфера.

Видеотест. Первый ролик,  «Ants carrying dead spider», нагружал видеопроцессор, а третий,  «Low Angle Of Pedestrians Walking In Busy Street, Daytime», декодер и, как следствие, видеопамять.

5b2b08bea5311a405f70469e979c05af.png50ec7bb59042217bf4a714566e5d9cee.pngdfa33667342f4d6f310d73e070e77a79.png4afb65dd107636bebd7eafcb3d3d274a.png

Общие сетевые метрики

Основную нагрузку при включении RemoteFX составляет UDP трафик, а по TCP передаются, например, нажатия клавиатуры. Соответственно, TCP-трафик на несколько порядков меньше, колеблется в пределах от сотен до тысяч бит в секунду.

Здесь приведены общие счётчики, графики в разрезе протоколов вынес в приложение. Помечены как UDP трафик и TCP трафик

Основные всплески в графиках вызывало проигрывание локального видеофайла «Flying Through Forest 1». Видимо, в эти моменты поток данных упирался в ограничение скорости канала связи со стороны сервера, 50 Мбит/с.

e1766ec39d94ea1d4affef82e8ddeba9.png36138b0dd07177ccb1fd9758ebed3a11.png9246b51a7bf06edcc3d63f602fe1f9fa.png

Заключение

NVIDIA GRID M60 позиционируется (1,  2) как производительное решение виртуализации рабочих мест, столов и приложений для дизайнеров, разработчиков, офисных и прочих сотрудников. Эту видеокарту можно использовать на 32 виртуальных машинах максимум, выделив по 512 МБ видеопамяти на каждую ВМ. В нашем случае профиль 'M60–1Q' был шире, на 1 ГБ.

Как показывают тесты, масштабирование виртуального сервера с этим видеопрофилем на несколько пользователей упирается, в первую очередь, в общие ресурсы виртуальной машины — vCPU и vRAM. Для оценки влияния именно количества пользователей на производительность, любой тест запускался у всех пользователей одновременно. Поэтому пики тестовой нагрузки складывались друг с другом и требовали больше ресурсов для комфортной работы: 2 ядра vCPU и 1 ГБ vRAM на пользователя. При таких настройках видеокарта «вытягивала» 2–3 одновременных сеанса, в зависимости от теста.

В реальности, конечно же, ресурсы подбирают исходя из конкретных задач, средней и максимальной нагрузки на сервер. Например, если основная часть работы пары десятков сотрудников носит «офисный» характер — документы, почта, браузер, 1С — вполне можно использовать один терминальный сервер с таким vGPU, подстраивая ресурсы ЦПУ и RAM по мере необходимости под число пользователей.

Если же речь идёт об обработке 3D графики, то вряд ли нагрузка на vGPU будет такая же постоянная и интенсивная, как в 3D тесте. Скорее всего, с учётом увеличения ресурсов по сравнению с «офисной» конфигурацией, этот видеопрофиль позволит нормально работать 3–5 пользователям. Впрочем, для действительно сложных 3D задач, виртуальный сервер можно отдать в монопольное использование одному сотруднику, масштабируясь по количеству серверов.

Приложения

Set-DataCollectors.ps1
<#
.SYNOPSIS
    Help to define PerfMon Data Collectors
    Need manual launch if number of terminal sessions is changed

.DESCRIPTION
    идея в следующем:
        Сбором метрик занимается админ, при каждом новом входе 
        по RDP подключился админ (1 пользователь)
        запустил этот скрипт вручную и для каждого из 5 тестов появился свой сборщик данных
            админ запускает 1й..5й тесты (выборочно или все)
            одновременно с тестом запускает соответствующий сборщик, который сохранит замеры в отдельный файл
            таким образом на каждый тест будет свой файл с замерами

        далее по RDP дополнительно подключается пользователь
        админ перезапускает скрипт для обновления сборщиков, т.к. нужно делать замеры RemoteFX уже для 2х пользователей
            админ запускает 1й..5й тесты
            появляются ещё логи (замеры), но теперь уже для 2х сессий

        далее по RDP дополнительно подключается ещё пользователь
            ... 

    в итоге будут замеры для каждого количества пользователей:
        для админа (1 пользователь)
        для 1 пользователя И админа
        ...
        для 5 пользователей И админа

.NOTES
    Author: Dmitry Mikhaylov aka alt-air
#>

$RootDir = '{0}' -f ($MyInvocation.MyCommand.Definition | Split-Path -Parent)
$LogsDir = '{0}' -f (Join-Path -Path $RootDir -ChildPath 'logs')
$LogFile = '{0}' -f (Join-Path -Path $LogsDir -ChildPath '{0}')
$cfgGeneral = '{0}' -f (Join-Path -Path $RootDir -ChildPath 'CountersGeneral.cfg')
$cfgSession = '{0}' -f (Join-Path -Path $RootDir -ChildPath 'CountersSession.cfg')

try  # импорт вспомогательного модуля
{
    Import-Module -Force (Join-Path -Path $RootDir -ChildPath 'helper.psm1' -Resolve)
}
catch
{
    'failed import helper.psm1' | Write-Output
    break
}

$TermSessions = @(Get-quser)  # terminal sessions

#region остановка всех старых сборщиков данных
foreach ($Counter in Get-Logman)
{
    if ($Counter.Status -match 'Running') { $null = logman stop $Counter.'Data Collector Set' 2>&1 }
    # elseif ($Counter.Status -match 'Stopped') { $null = logman start $Counter.'Data Collector Set' 2>&1 }  # для дебага команды logman
}
#endregion


#region удаление всех старых сборщиков данных
foreach ($Counter in Get-Logman)
{
    if ($Counter.Status -match 'Stopped')
    {
        $null = logman delete $Counter.'Data Collector Set' 2>&1
    }
}
#endregion


#region обновление конфига счётчиков
$CountersGeneral = @(  # общие метрики, сразу для всех сеансов
    # счётчики памяти работают, если они запущены в этом маленьком сборщике данных. При объединении в один сборщик они перестают фиксировать показания
    '\Memory\% Committed Bytes In Use'
    '\Memory\Available Bytes'

    '\Processor Information(_Total)\% Processor Time'
    '\NVIDIA GPU(*)\% GPU Usage'
    '\NVIDIA GPU(*)\% GPU Memory Usage'
    '\NVIDIA GPU(*)\% FB Usage'
    '\NVIDIA GPU(*)\% Video Decoder Usage'
    '\NVIDIA GPU(*)\% Video Encoder Usage'
)

$CountersSession = @(  # метрики сеансов
    '\User Input Delay per Session({1})\Max Input Delay'  # задержка в указанном сеансе

    # '\RemoteFX Network(RDP-Tcp {0})\*'
    '\RemoteFX Network(RDP-Tcp {0})\Loss Rate'
    '\RemoteFX Network(RDP-Tcp {0})\Current TCP Bandwidth'
    '\RemoteFX Network(RDP-Tcp {0})\Current UDP Bandwidth'

    '\RemoteFX Network(RDP-Tcp {0})\Total Sent Rate'
    '\RemoteFX Network(RDP-Tcp {0})\TCP Sent Rate'
    '\RemoteFX Network(RDP-Tcp {0})\UDP Sent Rate'

    '\RemoteFX Network(RDP-Tcp {0})\Total Received Rate'
    '\RemoteFX Network(RDP-Tcp {0})\TCP Received Rate'
    '\RemoteFX Network(RDP-Tcp {0})\UDP Received Rate'

    # '\RemoteFX Graphics(RDP-Tcp {0})\*'
    '\RemoteFX Graphics(RDP-Tcp {0})\Input Frames/Second'
    '\RemoteFX Graphics(RDP-Tcp {0})\Output Frames/Second'
    '\RemoteFX Graphics(RDP-Tcp {0})\Frame Quality'
    '\RemoteFX Graphics(RDP-Tcp {0})\Average Encoding Time'
    '\RemoteFX Graphics(RDP-Tcp {0})\Graphics Compression ratio'
    '\RemoteFX Graphics(RDP-Tcp {0})\Frames Skipped/Second - Insufficient Server Resources'
    '\RemoteFX Graphics(RDP-Tcp {0})\Frames Skipped/Second - Insufficient Network Resources'
    '\RemoteFX Graphics(RDP-Tcp {0})\Frames Skipped/Second - Insufficient Client Resources'
)

# общие счётчики в конфиг
$null | Out-File -FilePath $cfgGeneral
foreach ($c in $CountersGeneral)
{
    $c | Out-File -FilePath $cfgGeneral -Append
}

# счётчики сеансов в конфиг
$null | Out-File -FilePath $cfgSession
foreach ($c in $CountersSession)
{
    foreach ($s in $TermSessions)
    {
        $c -f $s.SESSIONNAME.Split('#')[1], $s.ID | Out-File -FilePath $cfgSession -Append
    }
}
#endregion


#region добавление новых сборщиков, по одному на тест, в зависимости от количества терминальных сессий

# Counters.cfg
foreach ($t in $TestDefinitions.GetEnumerator())  # -ct 
{
    $CNGen = "{0} users {1} general" -f ($t.Key, $TermSessions.Length)  # general data collector name
    $null = logman create counter -n $CNGen -f bin -max 10 -si 00:00:01 -rf ('00:0{0}:00' -f $duration) --v -ow -cf $cfgGeneral -o ($LogFile -f $CNGen)

    $CNSes = "{0} users {1} session" -f ($t.Key, $TermSessions.Length)  # session data collector name
    $null = logman create counter -n $CNSes -f bin -max 10 -si 00:00:01 -rf ('00:0{0}:00' -f $duration) --v -ow -cf $cfgSession -o ($LogFile -f $CNSes)
}
#endregion
Set-DataCollectors.cmd
@echo off

@REM смена кодировки нужна для powershell-скрипта "%~dpn0.ps1"
chcp 65001

@REM работаем в текущей папке скрипта
pushd "%~dp0"

@REM включаем расширения для переопределения переменных в цикле
setlocal EnableDelayedExpansion

@REM имена cmd и powershell скриптов должны совпадать
@REM powershell.exe -NoLogo -NoProfile -File "%~dpn0.ps1"
start "%~dpn0.ps1" /WAIT /B powershell.exe -Command "& {%~dpn0.ps1}"

@REM pause
Start-SyncedTest.ps1
<#
.SYNOPSIS
    Let select the RemoteFX benchmark and launch it

.DESCRIPTION
    Let select the RemoteFX benchmark and launch it
    показать список тестов на выбор, после выбора теста
        проверять, не запущен ли какой-либо тест
        завершить все неактуальные процессы с тестами
        каждые 3 минуты ровно в 00 секунд запустить выбранный тест

.NOTES
    Author: Dmitry Mikhaylov aka alt-air
#>

[CmdletBinding()]
param (
    $DelayValidation    = 0,    # magic number: delay before killing processes after validation running
    $DelayLogman        = 7,    # magic number: delay before running notepad after running main test
    $interval           = 0,    # waiting sync interval in minutes, batch file run it with '-interval 1'
    [switch] $gui       = $false
)


$RootDir = '{0}' -f ($MyInvocation.MyCommand.Definition | Split-Path -Parent)
$LogsDir = '{0}' -f (Join-Path -Path $RootDir -ChildPath 'logs')
$LogFile = '{0}' -f (Join-Path -Path $LogsDir -ChildPath 'match.csv')

try  # импорт вспомогательного модуля
{
    Import-Module -Force (Join-Path -Path $RootDir -ChildPath 'helper.psm1' -Resolve)
}
catch
{
    'failed import helper.psm1' | Write-Output

    break
}


#region select test and validation it through short running

try  # select and check the test
{
    if ($gui) { $t = $TestDefinitions | Out-GridView -Title 'Please, select RemoteFX test mode' -OutputMode Single }
    else
    {
        $TestDefinitions.Keys | Write-Output

        $key = @($TestDefinitions.Keys)[ ([int](Read-Host -Prompt 'select the test') <# - 1 #>) ]

        $t = $TestDefinitions.GetEnumerator() | Where-Object { $_.key -eq $key }  # иначе не работает нотация $t.Value.exe, только $t.Values
    }

    # $null = Start-Process -FilePath $t.Value.exe -ArgumentList $t.Value.param -WindowStyle Hidden  # -WhatIf появился только в PowerShell 7

    'debug message 1: {0} path to {1}' -f ((Test-Path -PathType Leaf -Path $t.Value.exe), $t.Value.exe) | Write-Warning
}
catch  # something go wrong...
{
    'no test selected or cannot run selected test {0}' -f $t.Key | Write-Output

    break
}
finally  # kill all possible old test processes
{
    $null = Start-Sleep -Seconds $DelayValidation  # magic number

    if ($env:USERNAME -match $manager)  # todo, bug here: need clear quser output to find current user is admin ('>')
    {
        # stop all data collectors
        foreach ($Counter in Get-Logman ) { if ($Counter.Status -match 'Running') { $null = logman stop $Counter.'Data Collector Set' 2>&1 } }

        $null = Clear-ProcessSpace
    }
}

#endregion


Sync-Tests -test $t.Key -period $interval


#region running selected test

$np = $TestDefinitions.GetEnumerator() | Where-Object {$_.Key -eq 'test #1 notepad'}

if ($null -eq $np)
{
    'debug message 2: cannot run notepad, $np is null{0}break...' -f "`n" | Write-Warning

    break
}

$ProcNpad = Start-Process -FilePath $np.Value.exe -ArgumentList $np.Value.param -PassThru
$ProcNpad.PriorityClass = 'Normal'  # Normal Idle High RealTime BelowNormal AboveNormal

$ProcTest = Start-Process -FilePath $t.Value.exe -ArgumentList $t.Value.param -PassThru
$ProcTest.PriorityClass = 'Normal'

$null = Start-Sleep -Seconds $DelayLogman  # delay for switching to notepad BEFORE start data collecting

#endregion


#region manage test process IF ADMIN (collect data, stop processes, logging, etc)

if ($env:USERNAME -match $manager)  # todo, bug here: need clear quser output to find current user is admin ('>')
{

    # stop all previous data collectors
    foreach ($Counter in Get-Logman )
    {
        if ($Counter.Status -match 'Running') { $null = logman stop $Counter.'Data Collector Set' 2>&1 }
    }

    # run selected test data collector
    foreach ($Counter in Get-Logman | Where-Object { $_.'Data Collector Set' -match $t.Key})
    {
        if ($Counter.Status -match 'Stopped') { $null = logman start $Counter.'Data Collector Set' 2>&1 }
    }


    # logging matching between username, rpd session number and session id
    $log = @()

    $TermSessions = @(Get-quser)

    foreach ($s in $TermSessions)
    {
        $log += New-Object psobject -Property @{
            'date'          = Get-Date -UFormat "%Y.%m.%d %H:%M:%S"
            'UTC offset'    = Get-Date -UFormat "%Z"
            'file'          = "{0} users {1} general.blg" -f ($t.Key, $TermSessions.Length)  # $t.Key
            'rdp user'      = $s.USERNAME
            'rdp session'   = $s.SESSIONNAME
            'rdp id'        = $s.ID
        }

        $log += New-Object psobject -Property @{
            'date'          = Get-Date -UFormat "%Y.%m.%d %H:%M:%S"
            'UTC offset'    = Get-Date -UFormat "%Z"
            'file'          = "{0} users {1} session.blg" -f ($t.Key, $TermSessions.Length)  # $t.Key
            'rdp user'      = $s.USERNAME
            'rdp session'   = $s.SESSIONNAME
            'rdp id'        = $s.ID
        }
    }

    $log | Select-Object -Property `
    'file',`
    'date',`
    'rdp user',`
    'rdp session',`
    'rdp id',`
    'UTC offset'`
    | Export-Csv -NoTypeInformation -Append -Force -Path $LogFile


    # kill process after test completed

    $WatchDog = [system.diagnostics.stopwatch]::startNew()  # tick-tack, WatchDog timer

    $durationS = $duration * 60 + 2

    while ($WatchDog.Elapsed.TotalSeconds -lt $durationS)
    {
        Write-Progress -Activity ("{0}" -f $t.Key) -SecondsRemaining ($durationS - $WatchDog.Elapsed.TotalSeconds) -CurrentOperation "waiting test completition"

        $null = Start-Sleep -Milliseconds 4987
    }

    # $ProcTest.Kill()
    # $ProcNpad.Kill()

    $null = Clear-ProcessSpace
}

#endregion
Start-SyncedTest.cmd
@echo off

@REM смена кодировки нужна для powershell-скрипта "%~dpn0.ps1"
chcp 65001

@REM работаем в текущей папке скрипта
pushd "%~dp0"

@REM включаем расширения для переопределения переменных в цикле
setlocal EnableDelayedExpansion

@REM имена cmd и powershell скриптов должны совпадать
@REM powershell.exe -File "%~dpn0.ps1"
start "%~dpn0.ps1" /WAIT /B powershell.exe -Command "& {%~dpn0.ps1 -interval 1 -gui:$false}"

@REM pause
helper.psm1
$duration   = 2         # test duration
$manager    = 'admin'   # username, who can manage test process  # production
# $manager    = 'user'    # username, who can manage test process  # debug

$TestDefinitions = [ordered] @{  # схема тестов
    # DEBUG ONLY
    "test #0 DEBUG"  = New-Object psobject -Property @{
        "exe" = "${env:ProgramFiles(x86)}\Windows Media Player\wmplayer.exe"
        "param" = @(<# "/Playlist", #> "$env:PUBLIC\Music\Playlists\mp4-qt-mp4.wpl", "/fullscreen")
    }

    "test #1 notepad"   = New-Object psobject -Property @{
        "exe" = "$env:SystemRoot\system32\notepad.exe"
        "param" = @("/w")
    }

    "test #2 FurMark"   = New-Object psobject -Property @{
        "exe" = "${env:ProgramFiles(x86)}\Geeks3D\Benchmarks\FurMark\FurMark.exe"
        "param" = @("/nogui", "/nomenubar", "/fullscreen", "/max_time=150000")
    }

    "test #3 WMPlayer"  = New-Object psobject -Property @{
        "exe" = "${env:ProgramFiles(x86)}\Windows Media Player\wmplayer.exe"
        "param" = @(<# "/Playlist", #> "$env:PUBLIC\Music\Playlists\mp4-qt-mp4.wpl", "/fullscreen")
    }

    "test #4 youtube"   = New-Object psobject -Property @{
        "exe" = "$env:ProgramW6432\Mozilla Firefox\firefox.exe"
        "param" = @("-new-window", "--kiosk", "https://www.youtube.com/embed/LXb3EKWsInQ?autoplay=1&end=140&fs=1&rel=0&loop=1")
    }

    "test #5 WebGL"     = New-Object psobject -Property @{
        "exe" = "$env:ProgramW6432\Mozilla Firefox\firefox.exe"
        "param" = @("-new-window", "--kiosk", "https://webglsamples.org/sprites/index.html")
    }
}


$users = @{
    'administrator' = 0
    '1'             = 1
    '2'             = 2
    '3'             = 3
    '4'             = 4
    '5'             = 5
}


function Get-quser  # запрашивает данные по активным терминальным сессиям и возвращает их в виде объекта
{   # https://devblogs.microsoft.com/scripting/automating-quser-through-powershell/
    # USERNAME SESSIONNAME ID STATE  IDLE TIME LOGON TIME
    # -------- ----------- -- -----  --------- ----------
    # 1        rdp-tcp#90  3  Active 1:46      13.01.2021 11:31
    # 2        rdp-tcp#49  4  Active 1:46      13.01.2021 12:19

    param ()

    $qusers = quser 2>&1 | `
        ForEach-Object -Process { $_ -replace '\s{2,}',',' } | `
        ForEach-Object -Process { $_ -replace '>','' } | `
        ConvertFrom-Csv | `
        Where-Object { $_.state -match 'active'}  # -and $_.username -notmatch 'administrator' }

    return $qusers
}

function Get-Logman  # запрашивает состояние сборщиков метрик и возвращает их в виде объекта
{
    # Data Collector Set Type    Status
    # ------------------ ----    ------
    #            Counter Stopped
    #            Counter Stopped

    param ()

    $pmOld = logman 2>&1 | Where-Object { $_ -notmatch 'successfully' -and  $_ -notmatch '[-]{2,}'} | ForEach-Object -Process { $_ -replace '\s{2,}',',' } | ConvertFrom-Csv

    return $pmOld
}


function Sync-Tests  # синхронизирует запуск тестов в разных RDP-сеансах
{
    param (
        [string]    $test   = 'no test',
        [int]       $period = 1  # test run every X minute
    )

    $FixTime = Get-Date

    try
    {
        $SecondsTotal = (60 * ($period - 1) - $FixTime.Second) * ($FixTime.Minute % $period -ne 0) + (60 * $period  - $FixTime.Second) * ($FixTime.Minute % $period -eq 0)
    }
    catch
    {
        $SecondsTotal = 0
    }

    while ( ((Get-Date).Second -ne 0 -or (Get-Date).Minute % $period -ne 0) -and $SecondsTotal -gt 0 )  # запуск теста в 00 секунд кратно интервалу
    {
        $null = Start-Sleep -Milliseconds (1000 - (Get-Date).Millisecond)

        $FixTime = Get-Date

        try
        {
            $SecondsLeft = (60 * ($period - 1) - $FixTime.Second) * ($FixTime.Minute % $period -ne 0) + (60 * $period  - $FixTime.Second) * ($FixTime.Minute % $period -eq 0)
        }
        catch
        {
            $SecondsLeft = 0
        }

        try
        {
            Write-Progress -Activity ("{0}" -f $test) -SecondsRemaining $SecondsLeft -CurrentOperation "waiting sync before running test" #-PercentComplete ($SecondsLeft / $SecondsTotal * 100)
        }
        catch { <# '{0} {1}' -f $SecondsTotal, $SecondsLeft #> }
    }
}


function Clear-ProcessSpace  # удаляет процессы, запущенные для теста
{
    param ()

    $ProcToKill = ($TestDefinitions.Values.exe | Split-Path -Leaf | ForEach-Object { $_.Split('.')[0] })

    foreach ($p in $ProcToKill) { Get-Process -name ('*{0}*' -f $p) | Stop-Process -Force }
}


Export-ModuleMember -Function * -Variable *
blg2csv.ps1
[CmdletBinding()]
param (
    [switch] $su       = $False  # substitute username instead session id/number
)

$WasError = $False
$ErrMessages = @()

$RootDir = '{0}' -f ($MyInvocation.MyCommand.Definition | Split-Path -Parent)
$LogsDir = '{0}' -f (Join-Path -Path $RootDir -ChildPath 'logs' -Resolve)
$LogFile = '{0}' -f (Join-Path -Path $LogsDir -ChildPath 'match.csv' -Resolve)

try  # импорт вспомогательного модуля
{
    Import-Module -Force (Join-Path -Path $RootDir -ChildPath 'helper.psm1' -Resolve)
}
catch
{
    'failed import helper.psm1' | Write-Output

    break
}


$EncodeFrom = [System.Text.Encoding]::GetEncoding(1251)
$EncodeTo = New-Object System.Text.UTF8Encoding $False

$Heads = @()  # для вывода заголовков в отдельный контрольный файл

# приводим заголовки csv файлов к единому виду, убираем ненужное
foreach ( $f in (Get-ChildItem -Path $LogsDir -File -Filter '*.csv' | Where-Object {$_.Name -notin @('match.csv', 'heads.csv')}) )
{
    $null = $f.BaseName -match 'users.(?[0-9]{1,}).*'

    $n = [int]$Matches['n']

    $csv = Import-Csv -Path $LogFile `
        | Where-Object {$_.file -match $f.BaseName} #`
        | Select-Object -Last $n

    $FileContent = $f | Get-Content -Encoding $EncodeFrom

    $HeadOrig = $FileContent[0]


    # "\\TESTGPU\NVIDIA GPU(#0 GRID M60-1Q (id=1, NVAPI ID=513))\% GPU Memory Usage"
    if ($HeadOrig -match '.*(?\\\\[a-zA-Z0-9]*\\).*') { $HeadOrig = $HeadOrig.Replace($Matches['hostname'], '') }

    if ($HeadOrig -match '.*NVIDIA GPU(?\(#[A-Z0-9 ]*-[A-Z0-9]* \(id=[0-9,]* NVAPI ID=[0-9]*\)\))') { $HeadOrig = $HeadOrig.Replace($Matches['gpu'], '') }


    # замена session на номер пользователя из $users
    while ($HeadOrig -match '(?\(RDP-Tcp.[0-9]{1,}\))')
    {
        if ($su)  # если нужно, то все данные во всех тестах будут одинаково промаркированы по номеру пользователя из хэштаблицы $users
        {
            # RDP-Tcp 19 в $Matches['session'] сравнить с rdp-tcp#19 в csv и заменить на $users['administrator']
            $s = ((($Matches['session'] -replace ' ', '#') -replace '\(', '') -replace '\)', '').ToLower()

            $c = $csv | Where-Object {$_.'rdp session' -eq $s}

            # перехват ошибки, когда сессия во время теста вылетела, а тесты были продолжены БЕЗ переустановки счётчиков
            try { $HeadOrig = $HeadOrig.Replace($Matches['session'], (' {0}' -f $users[$c.'rdp user'])) }
            catch
            {
                # бывает так, что сессия во время теста вылетела, юзер перезашёл и получил новый номер сессии
                # и если не остановить тест и не переопределить счётчики,
                # то в blg-файле окажутся пустые данные по счётчикам старой, вылетевшей сессии,
                # а в csv-файле будет указана уже новая активная сессия
                # и попытка отформатировать заголовоки упадёт с ошибкой.
                # чтобы узнать сбойный файл(-ы) и перезамерить тесты, ошибка перехватывается

                $WasError = $true

                $ErrMsg = "'{0}.blg'`n" -f $f.BaseName

                $ErrMsg += "`tin blg empty data for session`n`t`t{0}`n`tbut in CSV no such session`n" -f $Matches['session']

                $csv.'rdp session' | % {$ErrMsg += "`t`t{0}`n" -f $_}

                $ErrMsg += "`t{0}`n" -f $_

                $ErrMessages += $ErrMsg

                break  # break while
            }
        }
        else
        {
            $HeadOrig = $HeadOrig.Replace($Matches['session'], '')
        }
    }


    # замена id на номер пользователя из $users
    while ($HeadOrig -match '(?\([0-9]{1,}\))')
    {
        if ($su)  # если нужно, то все данные во всех тестах будут одинаково промаркированы по номеру пользователя из хэштаблицы $users
        {
            # (2) в $Matches['id'] сравнить с rdp id в csv и заменить на $users['administrator']
            $s = (($Matches['id'] -replace '\(', '') -replace '\)', '')

            $c = $csv | Where-Object {$_.'rdp id' -eq $s}

            # перехват ошибки, когда сессия во время теста вылетела, а тесты были продолжены БЕЗ переустановки счётчиков
            try { $HeadOrig = $HeadOrig.Replace($Matches['id'], (' {0}' -f $users[$c.'rdp user'])) }
            catch
            {
                # бывает так, что сессия во время теста вылетела, юзер перезашёл и получил новый номер сессии
                # и если не остановить тест и не переопределить счётчики,
                # то в blg-файле окажутся пустые данные по счётчикам старой, вылетевшей сессии,
                # а в csv-файле будет указана уже новая активная сессия
                # и попытка отформатировать заголовоки упадёт с ошибкой.
                # чтобы узнать сбойный файл(-ы) и перезамерить тесты, ошибка перехватывается

                $WasError = $true

                $ErrMsg = "'{0}.blg'`n" -f $f.BaseName

                $ErrMsg += "`tin blg empty data for session`n`t`t{0}`n`tbut in CSV no such session`n" -f $Matches['session']

                $csv.'rdp session' | % {$ErrMsg += "`t`t{0}`n" -f $_}

                $ErrMsg += "`t{0}`n" -f $_

                $ErrMessages += $ErrMsg

                break  # break while
            }
        }
        else
        {
            $HeadOrig = $HeadOrig.Replace($Matches['id'], '')
        }
    }


    # "(PDH-CSV 4.0) (Russia TZ 2 Standard Time)(-180)"
    if ($HeadOrig -match '.*(?
blg2csv.cmd
@echo off

@REM смена кодировки нужна для powershell-скрипта "%~dpn0.ps1"
chcp 65001

@REM работаем в текущей папке скрипта
cd "%~dp0logs"


@REM включаем расширения для переопределения переменных в цикле
setlocal EnableDelayedExpansion

@REM цикл по двоичным файлам мониторинга
FOR /F "usebackq delims=." %%a IN (`dir *.blg /b`) DO (
    set "blg=%%a.blg"
    set "csv=%%a.csv"

    @REM convert binary to csv
    relog "!blg!" -f csv -o "!csv!" -y
)

@REM имена cmd и powershell скриптов должны совпадать
@REM pwsh.exe -NoLogo -NoProfile -File "%~dpn0.ps1"
start "%~dpn0.ps1" /WAIT /B pwsh.exe -Command "& {%~dpn0.ps1 -su:$true}"

if %ERRORLEVEL% == 0 (
    exit
) else (
    pause
    exit
)


@REM справка reglog - утилиты работы с журналами производительности
@REM https://docs.microsoft.com/ru-ru/windows-server/administration/windows-commands/relog
figures.ipynb

Обработка замеров

ось Y одинаково масштабирована внутри одной метрики

Для понимания масштабирования vGPU на несколько терминальных сеансов были проведены следующие замеры:

  • три теста (см. 1ю часть): 3D тест, проигрывание локального плейлиста, проигрывание youtube-ролика

  • три случая: каждый тест воспроизводился в одном, двух и трёх терминальных сеансах одновременно

Таким образом по одной метрике будет построено 9 графиков:

  • в колонках показаны результаты тестов

  • в рядах — количество одновременных RDP-сеансов

          test2   test3   test4
        -------------------------
1 user  |       |       |       |
        -------------------------
2 users |       |       |       |
        -------------------------
3 users |       |       |       |
        -------------------------
# Подготовка данных
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import EngFormatter  # для вывода форматированных единиц измерения

pd.options.display.max_rows = 999
plt.rcParams['figure.max_open_warning'] = 99  # порог предупреждения при одновременном построении нескольких рисунков
%matplotlib inline

# импорт замеров трёх тестов в комбинации с 1, 2 и 3 одновременными сеансами RDP
# второй тест - 3d benchmark
t21 = pd.concat([pd.read_csv('./logs/test #2 FurMark users 1 general.csv', na_values=' '), pd.read_csv('./logs/test #2 FurMark users 1 session.csv', na_values=' ')], join='inner', axis=1)
t22 = pd.concat([pd.read_csv('./logs/test #2 FurMark users 2 general.csv', na_values=' '), pd.read_csv('./logs/test #2 FurMark users 2 session.csv', na_values=' ')], join='inner', axis=1)
t23 = pd.concat([pd.read_csv('./logs/test #2 FurMark users 3 general.csv', na_values=' '), pd.read_csv('./logs/test #2 FurMark users 3 session.csv', na_values=' ')], join='inner', axis=1)
# t23.info()

# третий тест - проигрывание локальных видеофайлов
t31 = pd.concat([pd.read_csv('./logs/test #3 WMPlayer users 1 general.csv', na_values=' '), pd.read_csv('./logs/test #3 WMPlayer users 1 session.csv', na_values=' ')], join='inner', axis=1)
t32 = pd.concat([pd.read_csv('./logs/test #3 WMPlayer users 2 general.csv', na_values=' '), pd.read_csv('./logs/test #3 WMPlayer users 2 session.csv', na_values=' ')], join='inner', axis=1)
t33 = pd.concat([pd.read_csv('./logs/test #3 WMPlayer users 3 general.csv', na_values=' '), pd.read_csv('./logs/test #3 WMPlayer users 3 session.csv', na_values=' ')], join='inner', axis=1)
# t33.info()

# четвёртый тест - просмотр youtube в 1080p60
t41 = pd.concat([pd.read_csv('./logs/test #4 youtube users 1 general.csv', na_values=' '), pd.read_csv('./logs/test #4 youtube users 1 session.csv', na_values=' ')], join='inner', axis=1)
t42 = pd.concat([pd.read_csv('./logs/test #4 youtube users 2 general.csv', na_values=' '), pd.read_csv('./logs/test #4 youtube users 2 session.csv', na_values=' ')], join='inner', axis=1)
t43 = pd.concat([pd.read_csv('./logs/test #4 youtube users 3 general.csv', na_values=' '), pd.read_csv('./logs/test #4 youtube users 3 session.csv', na_values=' ')], join='inner', axis=1)
# t41.info()

dataframes = [
    [t21, t31, t41],
    [t22, t32, t42],
    [t23, t33, t43],
]

fgs = [  # макет и свойства графиков: комментирование метрики отключает построение её диаграммы
    # n         номер рисунка для сортировки и вывода по-порядку
    # ysamescale    нужно ли использовать один масштаб по Y для всех графиков метрики
    # yscale    масштаб единиц измерения
    # yunit     единица измерения
    # ydata     колонка из датафрейма
    # desc      название рисунка

    # r'...\UDP...' это fix of (unicode error) 'unicodeescape' codec can't decode bytes in position 20-22: truncated \UXXXXXXXX escape    

    # общие счётчики
    {'n': 3,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'Processor Information(_Total)\% Processor Time', 'desc': 'Загрузка центрального процессора'},

    {'n': 7,    'ysamescale': True,     'yscale': 1,        'yunit': 'B',   'ydata': 'Memory\Available Bytes', 'desc': 'Доступная оперативная память'},
    {'n': 7,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'Memory\% Committed Bytes In Use', 'desc': 'Загрузка оперативной памяти'},

    {'n': 9,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'NVIDIA GPU\% GPU Usage', 'desc': 'Загрузка видеопроцессора'},
    {'n': 9,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'NVIDIA GPU\% GPU Memory Usage', 'desc': 'Загрузка видеопамяти'},
    {'n': 9,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'NVIDIA GPU\% FB Usage', 'desc': 'Загрузка фреймбуфера GPU'},
    {'n': 9,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'NVIDIA GPU\% Video Encoder Usage', 'desc': 'Загрузка энкодера GPU'},
    {'n': 9,    'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'NVIDIA GPU\% Video Decoder Usage', 'desc': 'Загрузка декодера GPU'},

    # счётчики сеансов
    {'n': 1,    'ysamescale': False,    'yscale': 0.001,    'yunit': 's',   'ydata': 'User Input Delay per Session {0}\Max Input Delay', 'desc': 'Максимальная задержка ввода на сеанс'},

    {'n': 5,    'ysamescale': True,     'yscale': 1,        'yunit': 'FPS', 'ydata': 'RemoteFX Graphics {0}\Input Frames/Second', 'desc': 'Графика RemoteFX, Входящих кадров в секунду', 'yMin': 0, 'yMax': 50},
    {'n': 5,    'ysamescale': True,     'yscale': 1,        'yunit': 'FPS', 'ydata': 'RemoteFX Graphics {0}\Output Frames/Second', 'desc': 'Графика RemoteFX, Исходящих кадров в секунду', 'yMin': 0, 'yMax': 50},

    {'n': 33,   'ysamescale': False,    'yscale': 0.001,    'yunit': 's',   'ydata': 'RemoteFX Graphics {0}\Average Encoding Time', 'desc': 'Графика RemoteFX, Среднее время кодирования кадра'},
    {'n': 33,   'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'RemoteFX Graphics {0}\Frame Quality', 'desc': 'Графика RemoteFX, Качество кадра'},
    {'n': 33,   'ysamescale': False,    'yscale': 1,        'yunit': '%',   'ydata': 'RemoteFX Graphics {0}\Graphics Compression ratio', 'desc': 'Графика RemoteFX, Коэффициент сжатия графических данных'},

    {'n': 22,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\Total Sent Rate', 'desc': 'Сеть RemoteFX, Общая скорость передачи'},
    {'n': 22,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\Total Received Rate', 'desc': 'Сеть RemoteFX, Общая скорость приёма'},
    {'n': 22,   'ysamescale': True,     'yscale': 1,        'yunit': '%',   'ydata': 'RemoteFX Network {0}\Loss Rate', 'desc': 'Сеть RemoteFX, Потери'},

    {'n': 44,   'ysamescale': True,     'yscale': 1,        'yunit': 'FPS', 'ydata': 'RemoteFX Graphics {0}\Frames Skipped/Second - Insufficient Network Resources', 'desc': 'Графика RemoteFX, Пропуск кадров из-за нехватки сетевых ресурсов'},
    {'n': 44,   'ysamescale': True,     'yscale': 1,        'yunit': 'FPS', 'ydata': 'RemoteFX Graphics {0}\Frames Skipped/Second - Insufficient Server Resources', 'desc': 'Графика RemoteFX, Пропуск кадров из-за нехватки ресурсов сервера'},
    {'n': 44,   'ysamescale': True,     'yscale': 1,        'yunit': 'FPS', 'ydata': 'RemoteFX Graphics {0}\Frames Skipped/Second - Insufficient Client Resources', 'desc': 'Графика RemoteFX, Пропуск кадров из-за нехватки ресурсов клиента'},

    {'n': 55,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': r'RemoteFX Network {0}\UDP Sent Rate', 'desc': 'Сеть RemoteFX, Скорость передачи, UDP'},
    {'n': 55,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': r'RemoteFX Network {0}\UDP Received Rate', 'desc': 'Сеть RemoteFX, Скорость приёма, UDP'},
    {'n': 55,   'ysamescale': True,     'yscale': 1000,     'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\Current UDP Bandwidth', 'desc': 'Сеть RemoteFX, Пропускная способность UDP-подключения'},

    {'n': 55,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\TCP Sent Rate', 'desc': 'Сеть RemoteFX, Скорость передачи, TCP'},
    {'n': 55,   'ysamescale': True,     'yscale': 1,        'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\TCP Received Rate', 'desc': 'Сеть RemoteFX, Скорость приёма, TCP'},
    {'n': 55,   'ysamescale': True,     'yscale': 1000,     'yunit': 'bps', 'ydata': 'RemoteFX Network {0}\Current TCP Bandwidth', 'desc': 'Сеть RemoteFX, Пропускная способность TCP-подключения'},
]

fgs.sort(key=lambda counter: counter['n'])

null = [print(f"{el['n']:>2} {el['ysamescale']:<1} {el['yunit']:^5} {el['desc']:<64}") for el in fgs]

# Поиск и сохранение пределов по оси Y в fgs
y_lim_scale = 0.13  # коэффициент пределов по Y: рамки графика должны быть чуть шире, чтобы график не сливался с ними

# сведение всех замеров в одну таблицу для поиска max/min
alldf = pd.concat([t21, t22, t23, t31, t32, t33, t41, t42, t43], axis=1, join='inner')
print('debug: должно быть 
    
            

© Habrahabr.ru