Skip to content

perf(host): write the log in batches, a few writes a second instead of one per line - #1675

Merged
hckrman101 merged 3 commits into
NotRexed:mainfrom
DUDUYG:perf/log-batch
Nov 10, 2026
Merged

hckrman101 merged 3 commits into
NotRexed:mainfrom
DUDUYG:perf/log-batch

Conversation

@DUDUYG

@DUDUYG DUDUYG commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

The problem

stderr is unbuffered (hos_main.c), so every line the log writer takes is a write of its own. In play that is 30–90 writes a second, each one extending hookshot_log.txt in the game folder.

With the game on a slow disk (a SATA SSD, 90% full, on my PC) those small appends keep the disk busy:

  • Its 4 KB reads take 10–500 ms, where they normally take 0.4 ms.
  • What the game reads from that disk meanwhile (pages of hookshot.exe, the game's files) waits just as long.

The fix

  • log_write joins the lines it is given into one fwrite. On the text-mode stream that is one WriteFile, or a few for a large batch.
  • After the line it is woken for, log_main waits up to HOS_LOG_BATCH_MS so the lines of that time go out together:
    • The default is 250 ms, the most is 2000, and 0 behaves as before.
    • The wait is on the existing condition variable: log_queue wakes it at each line.
    • When the queue is half full (1,024 lines), the batch goes out at once, before a burst can fill the queue. A full queue would make the thread that logs write the line itself.
  • Nothing waiting is lost. Every way out already writes the queue first (log_flush at exit, in hos_fatal and at the window's close, and log_crash_filter), and the writer holds no lock while it waits.
  • A line therefore reaches the file up to 250 ms later than before. A process killed from outside (TerminateProcess) could lose up to that much. Before, it could lose whatever the writer had not reached yet.

Measured

Test setup:

one write per line (HOS_LOG_BATCH_MS=0) batched (default)
game-disk 4 KB reads over 10 ms in play 46 / 12 / 0 0 / 0 / 2
game-disk 4 KB reads over 100 ms 6 / 1 / 0 0 / 0 / 0
worst game-disk read 202 / 526 / 7 ms 3 / 4 / 18 ms
frames over 50 ms 0 / 4 / 2 0 / 1 / 0
worst frame 40 / 111 / 367* ms 41 / 51 / 41 ms

* That 367 ms frame overlapped a page-file read on the other disk (memory pressure), not the game disk.

The effect is intermittent: one of the three unbatched runs was clean. In the second run, a cluster of 40–111 ms frames lined up with game-disk reads of 20–53 ms and with 800–2,000 pages read in by hard faults in each of those frames (hookshot.exe is on that disk). The average stays at 60 fps (capped).

Tests

tests/test_log_batch.py extracts the Windows log block of host/hos_thread.c unmodified, with fputs, fwrite and fflush as spies. It runs on real threads, SRW locks and condition variables, and checks five cases:

  • batch: with a 1 s wait, the lines of that time go out in one write, in order, and none before the wait ends.
  • default: with no setting, a line goes out after its 250 ms.
  • off: 0 writes a line at once.
  • burst: with a 2 s wait, 1,500 lines queued at once: the first 1,024 go out within 1 s, every line was queued, and none was written by the logging thread.
  • flush: log_flush writes what waits, and the writer's wait then ends with nothing written twice.

It is Windows only; run_all.py skips it on Linux. GCC's C front end warns that log_main (a for (;;) thread) has no return statement, so the fixture turns -Wreturn-type off around the extracted block only; clang, which builds the game, does not warn.

Results:

  • This PR passes all 5 cases, 4 times in a row.
  • main's writer fails batch, default and flush.
  • A plain Sleep in place of the condition-variable wait fails burst.

Also checked:

  • host/hos_thread.c compiles with -Wall -Wextra with the same single warning as main (an unused parameter at the file's end).
  • tests/run_all.py: 225 checks, 33 skipped. test_log_batch.py passes. Two checks fail for reasons of this PC, as on main:
    • test_precompile_compute_guard.py needs windows.h for an MSVC target.
    • test_nup_paths.py needs the symlink privilege.

🤖 Generated with Claude Code

DUDUYG and others added 3 commits November 10, 2026 09:56
…f one per line

stderr is unbuffered (hos_main.c), so each line the log writer took was a write of its own: 30-90 a
second in play, every one extending hookshot_log.txt in the game folder. With the game on a slow
disk (a SATA SSD, 90% full) those writes kept the disk busy: its 4 KB reads took 10-500 ms where
they take 0.4 ms, and what the game read from it meanwhile (pages of its own code, the game's files)
waited as long.

The writer now joins the lines it takes into one write, and after the line it is woken for it waits
up to HOS_LOG_BATCH_MS (default 250, at most 2000; 0: as before) so the lines of that time go
together. A queue half full goes out at once, before a burst can fill it (a full queue has the thread
that logs write the line itself). Nothing waiting is lost: every way out writes the queue first
(log_flush at exit and in hos_fatal, log_crash_filter), and the writer holds no lock while it waits.

tests/test_log_batch.py: the writer extracted from hos_thread.c with the CRT stream calls as spies
(the lines of that time in one write and in order, 0 keeps the old timing, a burst goes out without
the wait, log_flush writes what waits once). Windows only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wait

Windows CI builds the fixture with GCC, whose C front end warns on log_main: a static function with no return statement,
though its for (;;) never ends (clang, which builds the game, does not). The warning is off for the extracted block only.

The batch case checked nothing written 120 ms into a 250 ms wait; it now runs on a 1 s wait, and a default case checks
that a line with no setting waits its 250 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hckrman101
hckrman101 merged commit c7e41fd into NotRexed:main Nov 10, 2026
8 of 10 checks passed
DUDUYG added a commit to DUDUYG/Hookshot that referenced this pull request Nov 10, 2026
…1675 merged, NotRexed#1765)

NotRexed#1660 is in main now (its own commit): the conflicts were this branch's later changes of the same lines, pcache_init split
around pcache_file_read and the save test's play_cache_save checks, kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants