Полгода назад я начал писать сжатую файловую систему для macOS. Идея была довольно простой. В macOS уже существует decmpfs — встроенный механизм прозрачного сжатия файлов. Приложение продолжает видеть обычный файл, хотя физически его содержимое может храниться в сжатом виде. Но у этого подхода есть неприятное для моей задачи свойство: после изменения файла сжатие может исчезнуть.

Например, можно сжать node_modules, получить приличную экономию места, потом запустить сборку — часть файлов перезапишется, распакуется, и каталог постепенно снова начнёт занимать почти исходный объём. Мне хотелось другого поведения: если файл лежит на сжатом диске, он должен оставаться сжатым независимо от того, сколько раз его переписывали.

То есть компрессия должна быть не отдельной операцией над файлами, а свойством самой файловой системы. В macOS 15.4 появился FSKit — фреймворк Apple, позволяющий реализовывать файловые системы в пользовательском пространстве, без собственного kernel extension. Я решил попробовать. В итоге получился DenseDrive. Но эта статья не столько про него. Она про то, как за время разработки я несколько раз получал совершенно убедительные результаты, делал из них логичные выводы — а потом выяснял, что измерил вообще не то.

И чаще всего проблема была не в формуле, не в таймере и даже не в тесте. Проблема была в постановке эксперимента.

Сначала всё выглядело как история про скорость

У файловой системы, работающей в userspace, есть естественная цена. Когда приложение обращается к файлу, ядру приходится передавать часть операций процессу файловой системы и ждать ответ. В случае FSKit для этого активно используется XPC — механизм межпроцессного взаимодействия macOS. Если вы читаете один большой файл, несколько дополнительных переходов между процессами ещё не выглядят катастрофой.

Но если у вас 20 тысяч маленьких файлов и на каждом происходят lookup, запрос атрибутов, изменение атрибутов, открытие, закрытие и другие операции — стоимость этих переходов быстро становится заметной. Поэтому меня особенно заинтересовал Kernel‑Offloaded I/O (KOIO). В обычном режиме путь данных условно выглядит так: приложение → ядро → FSKit → процесс файловой системы → устройство. А с KOIO файловая система может сообщить ядру:

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

Эти физические участки называются экстентами — по сути, это диапазоны блоков устройства, соответствующие определённому диапазону файла. С KOIO путь сокращается до: приложение → ядро → устройство. Для несжатых данных результат был очень заметным. На одном из первых блочных тестов я получил около 611 МБ/с по обычному пути через FSKit/XPC и около 1445 МБ/с с KOIO. Почти в два с половиной раза быстрее. В тот момент мне казалось, что главная архитектурная проблема уже решена. Оказалось, что именно с этой оптимизации началась одна из самых неприятных ошибок проекта.

Скорость дважды стоила мне данных

В FSKit есть структура FSVolumeExtent, через которую файловая система сообщает ядру расположение данных. В документации к ней есть важная деталь: ядро может продолжать использовать переданный экстент, пока существует соответствующий vnode. vnode — это внутренний объект ядра, представляющий файл или каталог. Важно, что его жизнь не обязательно заканчивается в момент, когда приложение закрыло файл. Ядро может продолжать держать vnode и связанное с ним состояние. Я эту часть документации видел. Но, как позже выяснилось, не до конца осознал её последствия. У меня работало фоновое дожатие данных.

Файловая система брала уже записанные блоки, сжимала их и могла перемещать в другое место контейнера. С точки зрения моей логики старый участок после этого становился свободным, но ядро могло всё ещё помнить старую карту экстентов. Сценарий гонки был таким: файловая система передаёт ядру карту блоков → ядро её запоминает → фоновое сжатие перемещает данные → ядро продолжает писать по старому адресу. Результат — повреждение данных. Хуже всего было то, что происходило это не каждый раз. На тестовом корпусе из 14 885 файлов контрольные суммы расходились примерно раз в три прогона.

На мой взгляд, это почти идеальная частота для максимально неприятного бага. Если ошибка появляется каждый раз, её довольно легко локализовать. Если раз в тысячу запусков — сразу понятно, что перед тобой, вероятно, редкая гонка. А раз в три запуска означает, что можно найти логичное объяснение, исправить код, один раз успешно прогнать тест и решить, что проблема устранена. Я так сделал дважды. Сначала решил, что причина в гонке с кэшем. Потом — что достаточно дождаться deactivateItem. Обе гипотезы выглядели разумно. После обоих исправлений тест какое‑то время проходил. И обе гипотезы оказались неправильными.

В старых записях у меня сохранились результаты: 1903 из 14 885, затем 2806 из 14 885. Реальное правило оказалось гораздо жёстче:

Пока vnode жив, перемещать блоки, карту которых получил kernel, нельзя.

Никакой удобный callback не означает автоматически, что ядро окончательно перестало использовать старые экстенты. В итоге безопасное дожатие возможно только тогда, когда файл действительно больше не используется, а самый надёжный момент — при отключении тома. Это был первый полезный урок проекта:

Если после исправления симптом исчез, это ещё не означает, что вы нашли причину.

Особенно если симптом плавающий.

Потом я месяц оптимизировал вообще не то

Следующая проблема выглядела уже как обычная проблема производительности. Берём дерево из тысяч небольших файлов и начинаем копировать. Сначала всё быстро. Потом медленнее. Потом ещё медленнее. По ранним измерениям скорость падала примерно со 103 до 12 файлов/с; 12 тысяч файлов примерно за 545 секунд. Первая мысль — компрессия. Вторая — XPC. Это выглядело настолько очевидно, что я довольно долго копал именно эти два направления. В итоге нашлось три настоящих причины. И ни одна напрямую не была связана со скоростью компрессора.

Причина № 1: случайный O(N²)

macOS довольно часто вызывает synchronize — файловая система должна зафиксировать накопленные изменения. В моей первой реализации такой commit фактически сохранял полный снимок дерева. Чем больше файлов уже было создано, тем дороже становился следующий commit: стоимость росла примерно как 1, 2, 3, 4, …, N, а суммарно получалось 1 + 2 + 3 + … + N — классический O(N²). На тысяче файлов это ещё можно не заметить, на десятках тысяч — уже нельзя. Я всё это время смотрел на компрессию, хотя реальная деградация находилась уровнем выше.

Причина № 2: слишком дорогой statfs

Есть системный вызов statfs, через который macOS спрашивает у файловой системы общую ёмкость, свободное место и другие статистические данные. Моя первая реализация отвечала на этот вопрос обходом практически всего дерева. Один такой обход не страшен, но система спрашивала эти данные снова и снова. По сути получалось: создали файл → пересчитали дерево → создали следующий файл → снова пересчитали дерево. На небольших тестах это почти незаметно, на реальной директории — уже очень заметно.

Причина № 3: быстрый режим расходовал по 256 КиБ на файл

Для KOIO первоначально было проще выделять каждому файлу полный блок размером 256 КиБ. Большой файл от этого практически не страдает, но если у вас десятки тысяч файлов размером 2–10 КиБ, ситуация становится абсурдной: например, файл размером 4 КиБ физически занимал 256 КиБ. В некоторых тестах занятое место раздувалось примерно в пять раз. После исправлений 20 тысяч файлов стали копироваться примерно за 27 секунд — около ×40 быстрее. И это оказался довольно неприятный момент: я почти месяц оптимизировал компрессию, а квадратичная сложность всё это время сидела в commit. Отсюда второй урок:

Место, где проявляется проблема, и место, где находится причина, могут вообще не совпадать.

Лучшее улучшение сжатия оказалось не связано со сжатием

Примерно в это же время коэффициент компрессии застрял около ×1.61. Я уже начал думать о следующем логичном шаге — словарях Zstandard. Zstandard (Zstd) умеет использовать заранее подготовленный словарь типичных последовательностей. Для директорий вроде node_modules, где постоянно повторяются одни и те же строки и структуры, это должно работать особенно хорошо. Но перед тем как лезть в компрессор, я написал маленькую диагностическую утилиту. Она показывала не размер сжатых данных, а то, сколько места каждый файл реально занимает внутри файловой системы. И здесь всё стало гораздо интереснее.

По одному из ранних замеров, из 89 МБ занятого пространства около 49 МБ приходились на выравнивание. Типичный пример: исходный файл — 8 КиБ, после сжатия — 2 КиБ, а физически на диске всё равно занято 64 КиБ. Компрессор свою работу сделал прекрасно, просто файловая система после этого выбросила почти весь выигрыш на alignment. А файлов меньше 64 КиБ в моём тестовом корпусе было большинство. После того как я убрал лишнее выравнивание, коэффициент изменился: ×1.61 → ×3.50.

То есть больше половины выигрыша я получил без смены алгоритма, SIMD, ассемблерных оптимизаций и настройки уровней Zstd. Просто потому, что впервые посмотрел на правильную метрику: не сколько байт выдал компрессор, а сколько байт реально занято на диске.

Позже словарь Zstandard действительно помог и поднял результат примерно до ×4.31. Для сравнения, теоретический результат solid‑сжатия всего корпуса был около ×4.52. Но главный скачок произошёл раньше и вообще не в компрессоре.

Потом один Release‑build почти отменил всю аргументацию за быстрый режим

К этому моменту у файловой системы было два режима. Условно: compact — данные проходят через FSKit, зато место используется максимально эффективно; fast — активно используется KOIO, зато приходится идти на некоторые компромиссы по размещению данных. Fast существовал в первую очередь ради производительности. Ведь у меня были красивые результаты вроде: 611 МБ/с → 1445 МБ/с А потом я собрал приложение в Release. И обнаружил несколько неприятную деталь: почти все предыдущие замеры я делал на Debug‑сборке. В Debug мой код работал примерно на 17–43% медленнее. Повторяю тест с копированием файлов. Получаю:

Режим

Debug

Release

compact

465 файлов/с

580 файлов/с

fast

501 файлов/с

588 файлов/с

Разница между режимами в Release — около полутора процентов. При этом compact держал пик занятого места около 34 МБ вместо 180 МБ и отключал том примерно за 2,7 секунды вместо 9,4. Я посмотрел на цифры и сделал вполне логичный вывод:

Fast‑режим почти ничего не даёт. Его, вероятно, можно убрать.

Логичный. И неправильный.

На следующий день другой тест доказал обратное

Я запустил блочный тест. Не копирование дерева из тысяч файлов. Просто чтение и запись одного большого файла. Результат:

Тест

compact

fast

SEQ1M QD1, чтение

577 МБ/с

2795 МБ/с

SEQ1M QD1, запись

407 МБ/с

943 МБ/с

RND4K QD1, чтение

10,0 МБ/с

45,2 МБ/с

SEQ1M здесь означает последовательные операции блоками по 1 МБ. QD1 — queue depth 1, то есть в каждый момент в очереди находится одна операция. RND4K — случайный доступ блоками по 4 КиБ. Теперь разница была уже не полтора процента. А в два‑пять раз. Сначала кажется, что тесты противоречат друг другу. Но на самом деле они измеряют совершенно разные вещи. При копировании 14 885 маленьких файлов львиная доля времени уходит не на передачу содержимого.

Для каждого файла происходят lookup, получение и изменение атрибутов, создание объекта, работа с метаданными и множество XPC round‑trip. В таком workload пропускная способность самого I/O‑пути может вообще не быть главным ограничением. А один большой файл — ровно тот сценарий, где KOIO должен раскрывать преимущество. Получается, утверждение:

Fast почти бесполезен

на самом деле должно было звучать так:

Fast почти ничего не даёт при копировании большого количества маленьких файлов

Это уже совсем другой вывод. Fast‑режим остался.

А первый красивый benchmark тоже оказался неправильным

После этого я решил вернуться к старым результатам. В том числе к самому первому красивому тесту, где получил: 611 МБ/с → 1445 МБ/с И обнаружил ещё одну деталь. Один том в тесте был заполнен. Другой — пустой. Причём эта информация была написана прямо в заголовке моей же тестовой утилиты. На скриншоте. Перед глазами. Я несколько раз смотрел на эти результаты и не замечал. Заметил примерно на третий заход. Это был, пожалуй, самый полезный benchmark проекта — потому что после него появилось правило:

Один workload — это не benchmark suite. Это анекдот

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

Потом проблема выглядела как права Finder. Но права были ни при чём

Уже после релиза знакомый прислал мне скриншот. Finder отказывался удалить каталог с моего тома. По сообщению macOS всё выглядело так, будто файловая система неправильно работает с правами. Я начал проверять всё, что выглядело хоть немного подходящим: permissions, file flags, ACL, extended attributes, nlink, capability тома, имена каталогов и целостность контейнера. Полтора суток, несколько версий исправлений — и ни одна гипотеза не подтвердилась. Самое неприятное — ошибка практически не воспроизводилась программно.

Нормально работали rm -rf, unlink, Python rmtree, удаление открытых файлов и скриптовая очистка корзины. Проблема проявлялась именно в Finder. В какой‑то момент я запустил fs_usage — системную утилиту macOS, позволяющую увидеть реальные файловые операции процессов: открытия, чтения, записи, unlink, rmdir и возвращаемые системой ошибки. Лог получился огромным — порядка десяти миллионов строк. Но среди них нашлось всего две действительно важные операции: rmdirENOTEMPTY и unlinkatENOTEMPTY. ENOTEMPTY означает буквально:

Каталог не пуст

Finder считал, что удалил всё содержимое. Моя файловая система считала, что внутри ещё есть файлы. И тут стало понятно, куда смотреть.

Две строки кода, из‑за которых Finder пропускал файлы

Каталог обычно перечисляется не целиком за один вызов. Клиент запрашивает часть элементов. Файловая система возвращает их и специальный directory cookie. Cookie — это закладка:

Если попросишь следующую порцию, продолжай отсюда.

У меня эта закладка была обычным индексом массива:

var index = Int(cookie.rawValue)
// ...
nextCookie: FSDirectoryCookie(UInt64(index + 1))

Пока каталог не меняется, схема работает идеально. Но Finder при удалении ведёт себя примерно так: получает часть элементов, удаляет их и просит продолжить перечисление. Представим каталог: A B C D E F G H. Finder получил A B C. Следующая позиция — 3. После этого Finder удаляет A, B, C. Массив становится: D E F G H. И затем Finder говорит:

Продолжай с позиции 3.

Но позиция 3 теперь указывает уже на G. Получается, D E F пропущены навсегда. Finder их больше не увидит. После чего пытается удалить каталог. А там всё ещё лежат D, E и F. Файловая система вполне законно отвечает: ENOTEMPTY Особенно забавно, что Finder практически прямым текстом сообщал мне причину. В интерфейсе появлялась фраза, что некоторые объекты были пропущены. Я воспринимал её как стандартное сообщение об ошибке. А Finder буквально говорил:

Некоторые объекты были пропущены.

Хуже было другое. После неудачной операции Finder пытался откатить удаление. В одном тесте из 7153 файлов 116 в результате уже не находились ни на томе, ни в доступной пользователю корзине. Исправление было концептуально простым: cookie должен ссылаться не на позицию элемента в текущем массиве, а на стабильный идентификатор. Например, вместо «продолжай с элемента № 157» — «продолжай после node ID 834729». Удаление соседних файлов такой идентификатор уже не сдвигает. На само исправление ушло немного времени, на поиск причины — полтора дня. После этого у меня появилось ещё одно правило:

Если ошибка воспроизводится только в конкретной программе, сначала посмотри, какие системные вызовы эта программа реально делает.

fs_usage за несколько минут дал больше информации, чем восемь предыдущих гипотез.

SDK может говорить правду — и всё равно вводить в заблуждение

Ещё одна история произошла с минимальной версией macOS. Изначально приложение требовало macOS 26. Я решил проверить, можно ли опустить requirement. Посмотрел SDK. Из 39 используемых мной символов FSKit 38 были отмечены как доступные начиная с macOS 15.4. Компилятор был доволен. Availability‑checks были довольны. Проект собирался без ошибок. Я установил его на чистую macOS 15.4. Том не смонтировался вообще. Начал смотреть журнал diskarbitrationd.

diskarbitrationd — системный демон macOS, который участвует в обнаружении, распознавании и монтировании файловых систем. На системе, где всё работало, появлялись строки примерно такого вида: created filesystem, id = cdfs_fskit На macOS 15.4 их не было вообще. То есть проблема заключалась не в том, что мой код вызывает отсутствующий метод. Система просто не подключала FSKit‑модуль к этому сценарию монтирования. На 15.6 тот же код уже работал. И здесь я впервые очень наглядно увидел разницу между двумя утверждениями:

API доступен на этой версии macOS. и:

Весь системный механизм, необходимый для моего сценария, работает на этой версии macOS.

Это не одно и то же. Особенно когда между приложением и результатом находятся ещё Disk Arbitration, FSKit, kernel и несколько системных сервисов.

В итоге самым интересным оказался не компрессор

Когда я начинал этот проект, мне казалось, что главный вопрос будет примерно таким: «Насколько быстро получится делать прозрачное сжатие?» Сейчас я сформулировал бы его иначе: «Как доказать, что файловой системе можно доверить данные — и что мои собственные измерения действительно означают то, что я думаю?»

Скорость в итоге получилась. В блочных сценариях KOIO действительно может давать двух‑пятикратный выигрыш. Но большая часть полезного опыта оказалась вообще не про скорость. Плавающее повреждение данных примерно раз в три запуска. Квадратичная сложность commit там, где я месяц оптимизировал компрессию. Огромная потеря пространства из‑за alignment. Вывод об архитектуре, который следующий benchmark почти полностью отменил. Красивое сравнение, в котором два тестовых тома находились в разных состояниях. Пропадающие при удалении файлы из‑за обычного индекса массива.

API, существующий в SDK, но ещё не обеспечивающий рабочий end‑to‑end сценарий в системе. И ещё один важный результат — уже непосредственно про сжатие. На реальных данных цифры очень разные. У меня сейчас получается примерно так: node_modules — ×4.05, обычная рабочая директория — ×3.38, установленные игры и видео — около ×1.01. Последняя цифра, пожалуй, не менее важна, чем первые две. Если SSD забит видео, архивами и уже сжатыми игровыми ресурсами, никакого четырёхкратного выигрыша не будет. Сжимать там практически нечего. И это нормально. Есть ещё числа, которые я пока сознательно не превращаю в выводы.

Например, случайная запись блоками 4 КиБ у меня сейчас идёт примерно 7–12 МБ/с. Можно написать: «Random write медленный».

Но я пока не прогнал тем же инструментом и на той же машине аналогичный тест для APFS. Поэтому я не знаю, в два раза это медленнее APFS, в пять раз, на 20% или вообще является нормальным результатом для конкретного QD и режима синхронизации. У меня есть измерение. Но пока нет baseline — а значит, нет и вывода. Полгода назад я бы, скорее всего, уже придумал этому числу объяснение. Теперь сначала сравню.

DenseDrive уже работает как законченный продукт, но эта статья всё‑таки не о нём. Она о довольно неприятном свойстве системной разработки: можно очень точно измерить совершенно не то и получить настолько убедительную цифру, что потом несколько недель строить вокруг неё архитектуру.

В результате самая полезная привычка, которую я вынес из этого проекта, выглядит довольно банально: перед тем как объяснять результат, я теперь стараюсь сначала ответить на другой вопрос:

А что именно я сейчас измерил?