А чего стример в трусах то?
Как я встретил вашу маму вкатывался в стримы. Начало-начал началось в начале 2012, когда наткнулся на видос РедВектора с турнирки по Soul Calibur 5. А под ним замечательный коммент от Hakumen444 «а чего стример в трусах то, не солидно как-то)»
Я такой, стоп что? Что еще за стример? Это же что-то про системы хранения данных на магнитной ленте, да? А оказалось что существует интерактивный телевизор. Ну а потом был SC2… твич со стримерами со всего глобуса, и все такое интересненькое... а у меня как назло есть дурная привычка иногда спать, поэтому приходится записывать кучу каналов чтобы посмотреть пропущенное в 3 часа ночи. Потому что и VOD-ы не все пишут.
А вы помните? Помните?! Битрейты 1 мегабит и если кто-то включал 3 то все начинали жаловаться как лагает? А как вам 2+ часовой стрим весом 175 мегабайт? Сейчас это меньше трёх минут. А еще старый добрый rtmp, который умел дропать кадры видео оставляя хотя бы звук. Сейчас конечно тоже есть webrtc, но для стримов повальный hls с буферизацией случись что.
С лирическим отступлением покончено, перейдем к рассказу о том как я дошел до управлением физическим размещением файлов на диске.
Хьюстон, у нас проблема
Как и раньше, сейчас основное устройство хранения это жесткие диски. И если тогда они легко справлялись с игрушечными интернетами, то сейчас битрейт может быть и 10 мегабит на стрим. Казалось бы даже в 10 потоков мы укладываемся в лимиты, но есть одно но.
Как-то вечером просматриваю записи и чувствую что они открываются и перематываются с ощутимой задержкой. Замер скорости чтения файла показал 20-50 мегабайт в секунду, что для полугодичного диска с хорошим смартом ненормально. Для меня стримы это 99% твич, поэтому запись идет в контейнер ts который лишен оглавления, поэтому плееру нужно сначала угадать примерное место в файле, а потом дочитать до запрошенного времени, отсюда и требования к скорости носителя.
Открываю дефрагментатор и тут моя челюсть с грохотом падает на стол. Я увидел кровь кишки распидорасило весь диск окрашенный в красный цвет, то есть в каждом квадратике, на который дефрагментатор разбил диск, лежал хотя бы один кусочек файла! И чемпион на 40 гигабайт имел 94370 фрагментов!
И это на выделенном для записей разделе в два терабайта из которых занято 200 гигабайт. Ради интереса включил дефрагментацию, и в кои то веки увидел результат в виде маленькой полосочки файлов в уголке вместо красного полотна на весь диск.
Кто виноват…
Сейчас, в эпоху доминирования HLS, основным ПО для записи является streamlink. Эта утилита написана на python, и, судя по исходникам, для записи на диск она юзает дефолтный 8 килобайтный буфер. То есть если нужно записать 1 мегабайт будет 128 записей с изменением размера файла. Отсюда и дикая фрагментация, так как часто пишутся несколько стримов параллельно. Да, есть кеширование операционной системы и самого python, поэтому записей будет меньше, но 100K фрагментов на файл говорят что этого недостаточно.
…и что делать
Очевидно что нужно аллоцировать место большими блоками. Как базу возьмем 100 мегабайт, это примерно половина средней скорости современного жесткого диска.
Но просто увеличить буфер будет не самым хорошим решением. В случае отключения питания или другого сбоя данные потеряются, невозможно будет посмотреть последние пару минут записи пока стрим пишется, и к тому же подобные крупные блоки в несколько потоков для жесткого диска будут проблемой.
Поэтому выбран вариант с преаллокацией. Пишем по 8 килобайт, но увеличиваем размер файла ступеньками по 100 мегабайт если 8 килобайт не умещаются. По быстродействию, расширение файла, это почти бесплатная операция не зависящая от размера, ну а в случае отключения питания нолики в конце будут отброшены муксером. Бонусом в файловых менеджерах теперь всегда актуальный размер а не нулевой.
Говоря о ноликах в преаллокации. Были эксперименты по заполнению корректными null-pts пакетами, но ни к чему не привели кроме увеличения нагрузки на диск. Да, если открыть файл во время записи то в конце плееры будут подвисать в любом случае, но зато больше не виснут при просмотре других частей файла, как с дефолтным буфером. Нужно просто держать в уме эти пару виртуальных последних минут.
A three weeks later...
И вот у нас снова занято примерно 200 гигабайт, проанализируем что же там по фрагментам. А отличие в… размере, теперь это в основном 100 MB. Например файл на 35.5GB теперь имеет 361 фрагмент. Из них 220 размером 100 MB, 17 фрагментов меньше 50 MB, 63 фрагмента больше 100 MB. При скорости считывания в 180-200 мегабайт в секунду на производительность оно уже практически не влияет и на этом можно было бы уже закончить но тогда не было бы этой статьи.
Главный вопрос можно ли увеличить размер фрагментов еще больше. По понятным причинам просто увеличить размер буфера преаллокации будет не самой хорошей идеей, поэтому пойдем другим путем.
Ёж птица гордая, пока не пнёшь не полетит
Предположим что файловая система не сразу заполняет освободившиеся кластеры другими файлами, а при аллокации блоков вначале использует свободное место впереди файла. В тесте при каждой новой аллокации расширяем файл сразу на 5 гигабайт а затем уменьшаем до +100 мегабайт.
Это подтверждается. Если стартуют сразу несколько воркеров то виден четкий паддинг в 5 гигабайт между файлами без фрагментации. Но как только этот буфер свободного места заканчивается то при выделении нового куска точно так же начинается чередование по 100 MB. При этом иногда воркеры пересекаются и новая аллокация будет в другом месте с запасом.
Выглядит это как несколько фрагментов по 100 MB, затем кусок больше гигабайта, и потом опять фрагменты по 100 метров. Отдельные файлы при этом будут все равно состоять только из 100 MB фрагментов, а другие будут записаны непрерывным куском. Из особенностей файловая система начинает очень сильно разбрасывать файлы по всему диску.
Бабуська, туда не ходи, сюда ходи, а то снег в башка попадет
Итак, из прошлого теста выяснили что нужно лишь иногда помогать файловой системе находить точку куда писать файл с достаточным количеством свободного места впереди.
Суть нового алгоритма. Разбиваем весь диск на большие виртуальные сегменты, например по 5 гигабайт. При аллокации нового 100 мегабайтного блока смотрим увеличился ли ран-лист в файле. Если число фрагментов изменилось то по какой то причине мы оказались фрагментированы. Поэтому уменьшаем размер файла до полного куска + 1 кластер, переносим этот кластер в начало нового пустого сегмента, а затем увеличиваем размер файла до запланированного. Далее стандартный аллокатор продолжит писать файл с нового сегмента.
Звучит просто но тут проблема, алгоритм изменяет физическое расположение файлов на диске и без прав администратора система нам этого не позволит. Поэтому, чтобы не запускать все подряд с повышенными правами, напишем службу с реализацией алгоритма распределения, а воркеры уже будут его просить аллоцировать новый кусок в файле.
Вот неделя, другая проходит...
Ну, что можно сказать, теперь в статистике мы видим фрагменты на 5+ гигабайт, как и просили. Это в идеале, если кроме записи стримов больше ничего не происходит.
На практике, файловая система очень любит приклеивать новые файлы к концу заполненных кластеров. Поэтому если есть другие дисковые операции, например ремуксы, то аллокатор будет вынужден переносить запись файла в следующий пустой сегмент и получаются дополнительные фрагменты, в основном от одного до пяти+ гигабайт. Но чаще заполняются дырки от паддинга между файлами.
Кстати о паддинге. Читая реализацию алгоритма возникает вопрос, а как же потери на выравнивание? Да это и не потери вовсе а просто рабочая область где файлы будут иметь большой размер сегментов. При исчерпании всех пустых сегментов аллокатор просто переключится на стандартный по 100 мегабайт и начнет заполнять диск полностью.
Но если интересно, то вот циферки. Статистика за 2 месяца уже после тестов. 1.8K файлов от 600 мегабайт до 60+ гигабайт, суммарным размером 15 терабайт. Мусор в ~300 файлов меньше 600 мегабайт в статистику не включен. Для моего случая потери на паддинг в зависимости от размера сегмента от 1 до 10 гигабайт: 1-5.8%, 2-11.2%, 3-15.9%, 4-20%, 5-23.5%, 6-28%, 7-31%, 8-34.7%, 9-37.8%, 10-40.6%. На графике это почти линейная зависимость.
АААААААА... Аптимизация!
Когда пишешь что-то более менее низкоуровневое то хочется все заоптимизировать вусмерть. Но по тестам мы все равно упираемся в кеш и положение головки жесткого диска. Все замеры проводились на WD40EZAX.
Перемещение кластера не вносит больших задержек, типичное время 30-60mks в 90% случаев. Если перезаписывать файл то эта операция занимает уже десятки миллисекунд.
Отдельные операции чтения битмапа занимают единицы-десятки микросекунд. Но как только мы доходим до нового не кэшированного блока, обычно это самое последнее чтение, то получаем просадку в миллисекунды, а то и десятки миллисекунд. Из-за этого тормозить будет каждая новая аллокация. Это легко проверяется. Делаем запрос на аллокацию нового файла где-нибудь в терабайтной области на 200+ прыжков и получаем условные 20ms. Затем удаляем этот файл и повторяем запрос который выполняется уже за 1.5ms. Ну а полное сканирование битмапа просто из отдельного тестового приложения ожидаемо убирает тупняки полностью, на какое то время.
Говоря о 200+ прыжках и стратегии нахождения свободного сегмента. Да, у нас сервис который может запоминать найденный сегмент между вызовами и искать со следующего по кольцу иногда сбрасываясь в начало. Тогда почти не придется сканировать весь диск.
Но вернемся в реальность. Выделять под временные реалтайм записи много-терабайтный раздел это немного странно, поэтому остановимся на линейном поиске. С оптимизациями, в пределах 2 терабайт он занимает 0.4-2ms. Для сравнения аллокация без поиска это ~100 mks.
Также не стоит забывать что вызов этих функций не такой и частый. У меня в среднем примерно каждые 30 секунд запрос 100 мегабайт и каждые 15 минут поиск сегмента. Случай с исчерпанием всех свободных сегментов скорее исключение, и даже тогда пара миллисекунд на десятки секунд погоды не сделают.
Итого реальные оптимизации следующие. 1) Проверка первых байт битмапа в каждом сегменте и скип если он занят. По бенчмарку на 16 терабайтном разделе на рам диске полный поиск (512 мегабайт битмапа) в худшем случае занимает около 65ms и почти все время это получение карты диска из winapi. С быстрым скипом это 3ms на прыжках с прошлой аллокации. Реальная доля полных поисков на 200+ прыжков меньше 5 процентов. 2) Замер времени отдельных операций чтения битмапа и если последнее чтение превышает максимальное из прошлых в 2 раза то ищем новые 10 сегментов по алгоритму выше, по сути загоняя их в кэш. И следующие 10 операций поиска проходят быстро.
Код можно посмотреть тут.
Заключение
Суммарно это все заняло почти месяц периодических тестов и сбора статистики поэтому многое сюда не включено. Все эти эксперименты с типом буфера, его размером, предварительным паттерновым заполнением, матюки синхронизацией, системными событиями, путь от dll к клиент-серверу, а так же клевый дедлок из-за которого было потеряно 10 минут одного из стримов, к счастью это был ASMR стрим.
Стоило ли оно потраченного времени? Первый тест с преаллокацией несомненно да. Версия с сегментами была написана скорее в исследовательских целях потому что в сравнении с простой преаллокацией она уже не дает какого-то серьезного ускорения. Из плюсов файлы будут сгруппированы вначале диска где скорость выше, а также намного выше шансы восстановить файл после случайного удаления даже без MFT.
Самое забавное что эта проблема маскировалась лет так 10, пока что-то пошло не так и файлы не начало распылять по всему диску; при том что свободного места было более чем достаточно.
Зато теперь все красиво.