Skip to content

perf(obj-save): CRC из буфера без перечитывания файла при сохранении предметов (#3368)#3369

Merged
bylins merged 2 commits into
masterfrom
perf/player-frac-save-async
May 31, 2026
Merged

perf(obj-save): CRC из буфера без перечитывания файла при сохранении предметов (#3368)#3369
bylins merged 2 commits into
masterfrom
perf/player-frac-save-async

Conversation

@bylins
Copy link
Copy Markdown
Owner

@bylins bylins commented May 30, 2026

Проблема

Crash_frac_save_all на каждого игрока делал 4 синхронные файловые операции:

  1. запись .obj (предметы)
  2. чтение .obj обратно для CRC
  3. запись .time (таймеры)
  4. чтение .time обратно для CRC

Два из них — это перечитывание только что записанного файла исключительно ради вычисления CRC.

Решение (синхронное, без фоновых потоков)

  • FileCRC::update_from_content(uid, mode, data, len) — вычисляет CRC из готового in-memory буфера, не читая файл. Семантика need_save/upsert идентична check_crc(UPDATE_*).
  • .obj пишется в бинарном режиме, CRC берётся из того же буфера. Бинарный режим гарантирует, что байты файла == байты буфера на всех платформах (в текстовом режиме Windows транслировал бы \n\r\n, и CRC из буфера разошёлся бы с CRC файла).
  • Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, байт-в-байт как раньше), пишет одним fwrite, CRC из буфера.
  • Обработка ошибок записи прежняя: false + Crash_delete_files.
  • Добавлены раздельные замеры времени записи .obj/.time (лог при total > 10мс) — чтобы снять разбивку с прода и решить, нужна ли дальнейшая оптимизация.

Итог: 4 файловые операции → 2 (убраны оба чтения только что записанных файлов). Всё в главном потоке — без гонок, без тредов, без лишних пулов.

История

Первоначальный вариант (фоновая запись через пул потоков) заменён синхронным после ревью: fire-and-forget давал гонки записи одного файла, отсутствие упорядочивания, чтение до завершения записи на загрузке и тихое проглатывание ошибок. Подробный разбор, дерево решений и запасной план (fire-and-forget «по-правильному» — при необходимости) — в комментариях к PR.

Closes #3368

Copy link
Copy Markdown
Collaborator

@kvirund kvirund left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes

Подход в текущем виде требует переработки. Переход с синхронной записи на fire-and-forget через многопоточный пул, без сериализации по игроку, без барьера и без согласования с тем, что реально лежит на диске, ломает несколько инвариантов, которые синхронный код держал бесплатно. Ссылка на «аналог ChestSaver» некорректна: ChestSaver/IngrChestSaver — это параллельно, но синхронно (задачи дедуплицируются по ключу, run() блокируется на f.get() до возврата в главный цикл, лог пишется из главного потока). Здесь же — никакого из этих свойств.

Ниже разделил на явные регрессии (поведение изменилось относительно синхронного кода) и состояния гонки.


Явные регрессии (поведение хуже, чем было)

R1. Молчаливая потеря данных при ошибке записи.
Раньше провал записи файла возвращал false и звал Crash_delete_files. Теперь главный поток уже вернул true, обновил CRC и (для RENT_CRASH) вызвал ClearSaveinfo, а фон при ошибке всего лишь делает log(). Итог: предметы не записаны, но в crc.lst зафиксирован CRC, ретрая нет, ошибка наружу не всплывает. Для системы сохранения шмота это недопустимо.

R2. CRC больше не верифицирует то, что на диске.
Весь смысл FileCRC::check_crc — поймать порчу/несоответствие файла на диске. Теперь CRC считается из in-memory буфера в главном потоке, а файл пишет другой поток. Если фоновая запись частично или полностью провалилась (диск, краш в момент записи), в crc.lst окажется CRC, которому файл не соответствует. На следующем буте check_crc либо поднимет ложную тревогу о «несовпадении контрольной суммы», либо, наоборот, замаскирует реальную порчу. Мы сертифицируем буфер, а не файл.

R3. Текстовый режим + CRC из буфера => рассинхрон на Windows/Cygwin.
.obj пишется текстовым std::ofstream, CRC берётся из буфера. На Linux совпадает, но CONTRIBUTING требует сборки под Windows/Cygwin, где \n\r\n разойдётся с буфером и CRC не сойдётся. Оригинал перечитывал файл, поэтому совпадал всегда. Минимум — открывать .obj в бинарном режиме.

R4. Формат таймер-файла изменён.
write_timer_file пишет по info.time (т.е. time.size()), а оригинал и CRC-буфер — по rent.n_items. В штатном пути они равны, но при любом расхождении файл перестаёт соответствовать собственной CRC. Надо итерировать по rent.n_items, чтобы остаться побайтово как раньше.

R5. chmod-ошибки больше не логируются (оригинал логировал). Косметика, но это тоже понижение наблюдаемости.


Состояния гонки

C1. Конкурентная запись в один и тот же файл игрока (блокер).
Пул из hardware_concurrency/2 потоков, дедупа нет. Если один персонаж попадает в очередь дважды в пределах окна записи (автосейв RENT_CRASH из Crash_frac_save_all, наложившийся на quit/rent того же чара), два воркера пишут один .obj/.time параллельно → перемешанное/обрезанное содержимое = битый сейв. У ChestSaver этого нет за счёт дедупа + барьера.

C2. Нет гарантии порядка (блокер).
Даже без буквальной одновременности при >1 воркере очередь не FIFO по исполнению: старый сейв может лечь поверх нового (откат состояния / дюп: взял→выбросил за <100мс, на диск попало «взял»).

C3. Read-after-write больше не гарантирован.
Crash_load при входе/возврате из ренты читает файл синхронно в главном потоке; при fire-and-forget ничто не гарантирует, что последняя запись уже сброшена. Человеческий зазор обычно закрывает окно, но инвариант больше ничем не enforced.

C4. log() из воркер-потока.
enqueue зовёт log() прямо внутри фоновой задачи. log()/LogManager::Info написаны под однопоточный игровой цикл и синхронизации не имеют (m_senders обходится без мьютекса). ChestSaver сознательно логирует только из главного потока после f.get(). Нужно либо убрать лог из воркера (возвращать результат и логировать в главном потоке), либо доказать потокобезопасность пути логирования.


Прочее

  • Размер пула игнорирует конфиг #3367. Только что смерженный runtime_config.thread_pools_workers() уже используется в ChestSaver; здесь жёстко зашито hardware_concurrency/2. Каждый глобальный сейвер плодит собственный пул — оверсабскрайб потоков.
  • Мёртвый защитный resize. Для write-only stringstream tellp() == str().size(), ветка written < obj_content.size() недостижима; комментарий про «prealloc оставил нули в хвосте» вводит в заблуждение.

Итог

Память и арифметика CRC сами по себе корректны (глубокие копии obj_content/SaveInfo, паритет need_save), но архитектурно патч меняет контракт подсистемы сохранения: синхронная запись с верификацией файла на диске заменена на асинхронную запись без сериализации, без верификации и с молчаливым проглатыванием ошибок. C1/C2 — блокеры; R1/R2/R3 и C3/C4 — серьёзные. Прошу переработать подход (как минимум: сериализация по uid либо однопоточный пул для игроков; CRC, согласованный с тем, что реально записано; внятная обработка ошибок записи; лог только из главного потока).

@kvirund
Copy link
Copy Markdown
Collaborator

kvirund commented May 31, 2026

Куда движемся: переработка подхода

По итогам ревью отказались от fire-and-forget и идём синхронно. Ниже — что сделано, почему так, и запасной план на случай, если синхронного варианта окажется мало.

Почему не fire-and-forget

Исходная реализация — запись в фоновый пул без сериализации по игроку — даёт реальные гонки (конкурентная запись одного файла, отсутствие упорядочивания, чтение до завершения записи на загрузке) и тихо проглатывает ошибки записи. Параллелизм «как у ChestSaver» здесь не применим: ChestSaver распараллеливает работу по многим сущностям с барьером (f.get()), а Crash_frac_save_all((pulse/passes)%305) сохраняет 0–1 игрока за вызов — распараллеливать не по чему.

Что сделано

Ключевая оптимизация PR — CRC из буфера в памяти — сохранена, но синхронно и корректно:

  1. FileCRC::update_from_content(uid, mode, data, len) — CRC из буфера без чтения файла; семантика вставки/обновления и флага need_save идентична check_crc(UPDATE_*).
  2. .obj пишется в бинарном режиме, CRC берётся из того же буфера. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n\r\n, и CRC из буфера разошёлся бы с CRC файла).
  3. Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, байт-в-байт как раньше), пишет одним fwrite, CRC из буфера.
  4. Обработка ошибок записи прежняя: false + Crash_delete_files.

Итог: убраны 2 из 4 файловых операций (оба чтения только что записанных файлов). Всё в главном потоке → ни гонок, ни потоков, ни лишних пулов.

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

Замеры

Добавлены раздельные таймеры записи .obj и .time с логом при total > 10мс:

save_char_objects: <имя> items=N obj_io=.. time_io=.. total=..

Прежние 74мс из описания — это разница между двумя строками лога, а не замер; к тому же у игрока было всего 17 предметов, что намекает на фиксированную задержку на каждую файловую операцию (или даже частично на задержку самого логирования), а не на стоимость, пропорциональную размеру. Поэтому сначала снимаем реальную разбивку с прода.

Порядок решений

  1. Выкатить замеры + CRC из буфера → снять разбивку.
  2. Если остаток (две записи) укладывается в бюджет такта — готово, остаёмся синхронными. Самый чистый исход.
  3. Если не укладывается — переходим к плану Б. Пакетную обработку (батчинг) frac_save сознательно не рассматриваем: она повышает пропускную способность, но увеличивает задержку на такт (барьер блокирует цикл на самом медленном игроке пачки) и ломает смысл дробного сохранения — равномерно размазать нагрузку, без всплесков.

План Б: fire-and-forget, сделанный правильно (если понадобится)

Возвращаемся к фоновой записи, но без «forget»:

  • Отдельный выделенный поток записи, не общий с chest-сейверами. Переиспользовать пул ChestSaver здесь нельзя: (а) синхронный барьер chest-сейвера блокирует главный цикл на f.get() — в общей очереди он стал бы ждать за задачами player-сейвера, то есть задержки двух подсистем связались бы; (б) сериализация по uid требует закрепления задач за конкретным потоком (отдельные очереди на поток), чего у общего пула с единой очередью нет.
  • При ~1 сохранении на такт достаточно одного выделенного потока с FIFO-очередью: глобальная упорядоченность и отсутствие конкурентной записи одного файла получаются бесплатно, без хеширования по uid. Размер при необходимости берём из конфига (thread_pools_workers), но по умолчанию одного потока хватает с запасом.
  • Флаг «запись в процессе» по uid + ожидание в Crash_load: чтение дожидается незавершённой записи этого игрока (закрывает чтение до завершения записи).
  • Ошибки наружу: статус из потока записи, лог и обработка в главном потоке; CRC из буфера фиксируется только после успешной записи.

То есть кэш отложенной записи (write-back) с синхронизацией чтения. Заходим в это только если данные покажут, что синхронного варианта недостаточно.

kvirund added a commit that referenced this pull request May 31, 2026
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые
операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time
для CRC. Два чтения только что записанных файлов убраны.

- .obj пишется в бинарном режиме (std::ios::binary), CRC считается из
  того же буфера через FileCRC::update_from_content. Бинарный режим
  гарантирует совпадение байт файла и буфера на всех платформах (в
  текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера
  разошёлся бы с CRC файла).
- Crash_write_timer сериализует таймеры в буфер (rent + n_items записей,
  как раньше), пишет одним fwrite и считает CRC из буфера.
- Обработка ошибок записи прежняя: false + Crash_delete_files.
- Всё синхронно в главном потоке -- без гонок и без фоновых тредов.

Добавлены раздельные замеры времени записи .obj и .time с логом при
total > 10мс -- для снятия разбивки с прода и решения, нужна ли
дальнейшая оптимизация (фоновая запись).

Refs #3368, #3369
@kvirund kvirund force-pushed the perf/player-frac-save-async branch from efbdda4 to 7f85ebe Compare May 31, 2026 02:39
@kvirund kvirund changed the title perf(obj-save): фоновая запись файлов игрока (#3368) perf(obj-save): CRC из буфера без перечитывания файла при сохранении предметов (#3368) May 31, 2026
@kvirund
Copy link
Copy Markdown
Collaborator

kvirund commented May 31, 2026

Ветка PR форс-обновлена: асинхронный вариант (PlayerFileSaver + пул) заменён синхронной переработкой — CRC из буфера в памяти без перечитывания файла + бинарная запись .obj/.time. Подход и дальнейший план (включая запасной — fire-and-forget «по-правильному») описаны в комментарии выше. Заголовок и описание PR приведены в соответствие.

kvirund added a commit that referenced this pull request May 31, 2026
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые
операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time
для CRC. Два чтения только что записанных файлов убраны.

- .obj пишется в бинарном режиме (std::ios::binary), CRC считается из
  того же буфера через FileCRC::update_from_content. Бинарный режим
  гарантирует совпадение байт файла и буфера на всех платформах (в
  текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера
  разошёлся бы с CRC файла).
- Crash_write_timer сериализует таймеры в буфер (rent + n_items записей,
  как раньше), пишет одним fwrite и считает CRC из буфера.
- Обработка ошибок записи прежняя: false + Crash_delete_files.
- Всё синхронно в главном потоке -- без гонок и без фоновых тредов.

Добавлены раздельные замеры времени записи .obj и .time с логом при
total > 10мс -- для снятия разбивки с прода и решения, нужна ли
дальнейшая оптимизация (фоновая запись).

Refs #3368, #3369
@kvirund kvirund force-pushed the perf/player-frac-save-async branch from 7f85ebe to aadb53d Compare May 31, 2026 02:47
kvirund added a commit that referenced this pull request May 31, 2026
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые
операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time
для CRC. Два чтения только что записанных файлов убраны.

- .obj пишется в бинарном режиме (std::ios::binary), CRC считается из
  того же буфера через FileCRC::update_from_content. Бинарный режим
  гарантирует совпадение байт файла и буфера на всех платформах (в
  текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера
  разошёлся бы с CRC файла).
- Crash_write_timer сериализует таймеры в буфер (rent + n_items записей,
  как раньше), пишет одним fwrite и считает CRC из буфера.
- Обработка ошибок записи прежняя: false + Crash_delete_files.
- Всё синхронно в главном потоке -- без гонок и без фоновых тредов.

Добавлены раздельные замеры времени записи .obj и .time с логом при
total > 10мс -- для снятия разбивки с прода и решения, нужна ли
дальнейшая оптимизация (фоновая запись).

Refs #3368, #3369
@kvirund kvirund force-pushed the perf/player-frac-save-async branch from aadb53d to 2b7df6f Compare May 31, 2026 02:49
kvirund added a commit that referenced this pull request May 31, 2026
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые
операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time
для CRC. Два чтения только что записанных файлов убраны.

- .obj пишется в бинарном режиме (std::ios::binary), CRC считается из
  того же буфера через FileCRC::update_from_content. Бинарный режим
  гарантирует совпадение байт файла и буфера на всех платформах (в
  текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера
  разошёлся бы с CRC файла).
- Crash_write_timer сериализует таймеры в буфер (rent + n_items записей,
  как раньше), пишет одним fwrite и считает CRC из буфера.
- Обработка ошибок записи прежняя: false + Crash_delete_files.
- Всё синхронно в главном потоке -- без гонок и без фоновых тредов.

Добавлены раздельные замеры времени записи .obj и .time с логом при
total > 10мс -- для снятия разбивки с прода и решения, нужна ли
дальнейшая оптимизация (фоновая запись).

Refs #3368, #3369
@kvirund kvirund force-pushed the perf/player-frac-save-async branch from 2b7df6f to c434a03 Compare May 31, 2026 03:22
@kvirund
Copy link
Copy Markdown
Collaborator

kvirund commented May 31, 2026

Сузил PR до bare-minimum, закрывающего основную проблему тикета (read-back при сохранении предметов):

  • FileCRC::update_from_content — CRC из буфера в памяти (только режимы .obj/.time);
  • save_char_objects/Crash_write_timer — бинарная запись и CRC из буфера, без перечитывания файла;
  • замеры времени записи.

Остальная чистка CRC-подсистемы (сверка из буфера на загрузке вместо второго чтения файла, Crash_delete_files → прямой сброс CRC вместо чтения удалённого файла, перевод сохранения player-файла на буфер, удаление осиротевших check_crc/calculate_file_crc) уезжает отдельным PR с целью в эту ветку — чтобы не раздувать минимальный фикс.

kvirund added 2 commits May 31, 2026 06:32
Добавлена FileCRC::update_from_content(uid, mode, data, len): считает
CRC из готового буфера и обновляет crc_list. Семантика upsert и флага
need_save идентична check_crc(UPDATE_*), отличается только источник
данных -- буфер вместо повторного чтения файла с диска.

Нужна для устранения чтения только что записанного файла при
сохранении предметов игрока (см. #3368).
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые
операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time
для CRC. Два чтения только что записанных файлов убраны.

- .obj пишется в бинарном режиме (std::ios::binary), CRC считается из
  того же буфера через FileCRC::update_from_content. Бинарный режим
  гарантирует совпадение байт файла и буфера на всех платформах (в
  текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера
  разошёлся бы с CRC файла).
- Crash_write_timer сериализует таймеры в буфер (rent + n_items записей,
  как раньше), пишет одним fwrite и считает CRC из буфера.
- Обработка ошибок записи прежняя: false + Crash_delete_files.
- Всё синхронно в главном потоке -- без гонок и без фоновых тредов.

Добавлены раздельные замеры времени записи .obj и .time с логом при
total > 10мс -- для снятия разбивки с прода и решения, нужна ли
дальнейшая оптимизация (фоновая запись).

Refs #3368, #3369
@kvirund kvirund force-pushed the perf/player-frac-save-async branch from c434a03 to 70d967f Compare May 31, 2026 04:33
@bylins bylins merged commit 387b792 into master May 31, 2026
20 checks passed
@bylins bylins deleted the perf/player-frac-save-async branch May 31, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

63. Crash frac save 0.075703 seconds.

2 participants