Skip to content

Faster scanning: batched reads, readahead, threaded first scan - #461

Draft
1rhino2 wants to merge 16 commits into
mainfrom
perf-threaded-scan
Draft

Faster scanning: batched reads, readahead, threaded first scan#461
1rhino2 wants to merge 16 commits into
mainfrom
perf-threaded-scan

Conversation

@1rhino2

@1rhino2 1rhino2 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

scan performance, plus the build and test changes needed to trust it.

first scan, same box, heap size given:

 64MB   204ms -> 84ms
256MB   734ms -> 208ms
  1GB  2936ms -> 702ms

narrowing a dense match set went 1.95s to 0.48s, and the read syscalls for that narrow went 65695 to about 1100.

three changes do the work. process_vm_readv batched through iovecs where the kernel allows it, readahead so the scan is not stalled on the next chunk, and splitting the first scan across threads with an ordered merge. option threads drives the last one, 1 is serial.

the gate was byte identical output, not speed. same target, thread counts 2 through 64, twenty runs, diffed against a pre threading reference across every data type. the suite had to go first for that: it used to assert only that scanmem exited zero, it now asserts on match sets, and i checked it fails when a scan routine is deliberately broken instead of passing anyway.

correction to what this branch says about clang. commits here claim clang at -O1 and up builds a scan that returns wrong results, and mark the clang CI job non blocking on that basis. wrong, there is no miscompile. test/memfake plants values into a buffer nothing in that process reads back, so clang deletes the stores as dead and the target never holds them. configure's CFLAGS reach memfake as well as scanmem, which is why it tracked optimisation level. #462 fixes memfake and reverts the claim in BASELINE.md and the man page. scanmem at -O2 with memfake at -O0 is 23/23, which pins it.

a7229a0 overlaps #457, same sscanf fix plus a stricter bounded conversion there. drop mine at merge.

draft on purpose, nothing needs looking at until #457 lands.

1rhino2 and others added 16 commits August 19, 2026 14:28
the old suite ran a scan of each data type and checked scanmem exited
zero. that passes against almost any regression, a scan routine could
return half the matches or none and it would still be green.

memfake can now plant a known value a known number of times, and
rewrite only those slots on SIGUSR1. so a test scans for the value,
mutates, then narrows, and the narrowed count is exactly what was
planted. the process's own copies of the value (locals, spilled
registers) keep the old value and drop out, which is what makes it
exact rather than approximate.

covers int8/16/32/64, float32/64, bytearray including wildcards and
the >8 byte general loop, and a dense case where every slot in the
buffer is a match so buffer edge handling is actually exercised.

checked it fails when it should: flipping one < to <= in the
scanroutines length guard takes 7 of the 19 checks red.
no optimisation here, just something to measure against. times the
initial scan and the scan+narrow separately and takes the difference
as the narrowing cost, since scanmem has no per command timing.

each measurement restarts the target. first version of this reused one
memfake across runs and the second run scanned for a value the mutation
had already overwritten, which reported a fake 0.02s narrow. worth
knowing if anyone touches this.

baseline on 128MB / 16.7M matches: 1.33s initial, 1.95s narrow, 65695
pread64 calls. that call count is one per 2KB of match data, which is
the thing worth attacking.
the narrowing scan reads every match back through sm_peekdata, which
refilled its window one PEEKDATA_CHUNK (2048 bytes) at a time. that is
a syscall per 2KB of match data regardless of how much of the region
the scan is about to walk through.

keep a readahead window that doubles on each sequential refill up to
64KB and drops back to a single chunk on a real jump, so dense scans
get large reads and sparse ones do not pay for bytes they will not use.

two things had to change for it to actually take effect. a forward walk
lands exactly on base+size when it runs off the end of the window,
which hits the cache miss branch rather than the partial hit branch, so
the miss path had to stop treating that as a jump. and the head shift
only triggered when there was no room for missing_bytes, which in steady
state left just the couple of KB freed by the last shift, so every
refill collapsed back to one chunk.

128MB / 16.7M matches: 65695 pread64 calls down to 2212, narrow 1.95s
to 1.61s. the call count is 30x better but the wall time is only ~17%,
because at this match density the narrow is spending most of its time
in the scan routine and match bookkeeping rather than in syscalls. the
syscall win matters more where syscalls are dearer, seccomp filtered
containers and the like.

19/19 of the scan tests pass, and clean under asan+ubsan.
gprof on a 64MB all-matching target showed sm_peekdata at 67M calls and
add_element at 134M, once per byte scanned. Both are too big for gcc to
inline, so the loop was paying a real call for cases that do almost no
work: peekdata is a cache hit nearly every time (1120 preads for 67M
calls), and add_element is usually just a contiguous append.

Split the trivial case out of each into a small inline wrapper and leave
the general function alone as the fallback. No behaviour change, the
match sets are identical.

64MB, every 8-byte slot planted, median of 5:

  initial scan   0.67s -> 0.58s
  narrow         0.79s -> 0.48s

Checked the tests actually catch bugs here rather than just passing:
letting add_element_fast skip the contiguity check fails 6 of 19, and
over-permitting the peekdata hit test by 64 bytes fails the dense case
(254208 of 262144 matches kept).
parse_uservalue guards each cast with `num <= UINT64_MAX` / `<= INT64_MAX`,
but neither constant has an exact double. Both round up, to 2^64 and 2^63,
so the guard admits the boundary itself and the cast right after it is out
of range and undefined.

Reachable from normal input. Typing 18446744073709551616 as a number, with
a UBSan build:

  value.c:194:9: runtime error: 1.84467e+19 is outside the range of
  representable values of type 'long unsigned int'

Bound by the power of two and keep the comparison strict. The 32 bit and
smaller limits are all under 2^53 so they convert exactly and are left
alone.

Also silences two clang -Wimplicit-const-int-float-conversion warnings,
which were pointing straight at this.
The build workflow had never actually run. It was pinned to ubuntu-22.04
with actions/checkout@v3, built gcc only, and passed the sanitizer flags
through `make CFLAGS=...`, which replaces what configure worked out and
never reaches LDFLAGS, so nothing got linked against the runtime.

Set the flags at configure time instead and split the sanitizers into
their own job, so a plain build failure and a sanitizer failure are told
apart. float-cast-overflow is asked for explicitly since gcc leaves it
out of -fsanitize=undefined, and it is the check that caught the 64 bit
parse bound bug in the previous commit.

The setup action takes cc/cflags/ldflags/configure-args now so the matrix
can drive it. Defaults match the old behaviour so the coverity workflow
is unaffected. Dropped `apt upgrade` from it, it pulled in a lot for no
benefit and was the slowest step in the run.

Checked locally first: gcc and clang both build warning free, and the
asan+ubsan build passes make check with no sanitizer output.
Adds process_vm_readv in front of the existing backends. It copies
straight between address spaces with no /proc file and no VFS layer, and
on its own it is a lot faster: reading 200MB in 64KB chunks out of
another process goes 5.3 GB/s on pread to 14.7 GB/s here, about 2.8x.

End to end it is worth much less than that, and it is worth being honest
about why. After the readahead change a 64MB scan only issues about 1120
reads, so syscalls are already under 1% of the run. On a read heavy scan
(256MB, value absent, so almost nothing gets stored) it comes out around
3%: 702ms vs 722ms best of 3. On the dense benchmark it is a wash. The
scan is CPU bound at this point, not read bound.

Keeping it anyway. It is never slower, it is what #400 asked for, and it
gives a working path on setups where /proc/pid/mem reads are restricted
but process_vm_readv is allowed.

Not always permitted though, so a refusal (ENOSYS/EPERM) disables it for
the session and the read is retried on the old path rather than paying a
failing syscall every time. A short read is not treated as a refusal,
that just means the end of a mapping, same as pread.

peekbuf.pid was only kept in the non-procmem build, it is needed either
way now.

SCANMEM_NO_PROCESS_VM_READV=1 forces the old path, which is how the two
were compared. Both backends return byte identical match lists when the
same target is scanned twice (10000 listed addresses, no diff), and the
suite passes on each.
The first scan runs the scan routine at every byte offset of every region
and nothing about one offset depends on another, so it parallelises. The
narrowing scan is left alone, it walks an existing match list and is
already cheap by comparison.

Splitting by region would not have helped much. The usual case is one big
region and a handful of small ones, so a per region split leaves eleven
threads idle. Regions get cut into chunks instead.

The part that does not split is emitting the records: a match of length L
is followed by L-1 filler entries, so a chunk boundary can land in the
middle of one. A chunk works out its own carry by rescanning the L-1
bytes behind it, which is enough because a match resets that counter
rather than adding to it, so only the last match before the boundary can
still be carrying. For number scans that window is 7 bytes.

Chunks record into their own buffers and a serial pass stitches them in
address order through the same add_element calls the serial scan made, so
the swath layout is byte for byte what it was. That pass is O(matches),
not O(bytes), which matters: doing it per byte cost more than the
threading saved and showed up as a fixed ~350ms that would not scale away.

Slices are handed out as threads free up rather than all being built up
front, so a process with a large mapped address space does not need a
descriptor per slice before any scanning starts.

`option threads N` picks the count, 0 (the default) means one per online
CPU, 1 scans serially. Best of 3, 1000 matches, this box has 12 cores:

           serial   threads=12
   64MB     204ms         84ms
  256MB     734ms        208ms
   1GB     2936ms        702ms

threads=1 comes out level with the old serial path.

Verified against a build of the previous serial code: identical match
lists for int8/16/32/64, float, and bytearray including wildcards and the
12 byte general loop, and identical across 20 runs at thread counts from
2 to 64. The suite now checks this itself by comparing a run split into
4096 byte chunks against one big enough to have no boundaries at all,
which is the comparison that actually detects a broken carry. Comparing
two thread counts does not: both go through the same chunking, so both
come out wrong the same way and still agree. Confirmed it fails when the
carry is deliberately removed.

asan and ubsan are clean on the threaded scan. tsan could not be used,
it hangs on this workload with threads=1 as well, so it is not the
threading, and the same hang happens on the serial build.
Kept on a branch while it was being built because a bad parallel merge
returns a wrong match set rather than crashing, so it fails quietly.
Merging now that the split vs unsplit comparison is in the suite and
passing, and the numbers hold up.
configure probes each flag with -Werror and keeps the ones the compiler
takes, so this does not break a build with a different or older compiler.
Ends up as -Wextra -Wno-unused-parameter -fstack-protector-strong
-Wformat=2 -Wno-format-nonliteral -Wvla -D_FORTIFY_SOURCE=2 on gcc 15,
and the standard moves gnu99 to gnu11.

Two suppressions, both deliberate:

-Wno-unused-parameter, because the scan routines are macro generated onto
one shared signature so most of them really do not use every argument.
That is about 130 warnings all saying the same thing and none of them a
bug.

-Wno-format-nonliteral, because the single place it fires picks its
format from a fixed internal table by scan type, not from input.

What the new flags turned up, all fixed here:

handlers.c, -Wclobbered: `seconds` and `cont` in handler__set are written
before the setjmp and read after it. A non volatile local is
indeterminate once longjmp has been through, so they are volatile now.

show_message.c, -Wunused-result: the pager child ran read() and write()
on the status pipe and then called exit(errno), except errno by then
belonged to whichever of those two ran last, not to the execvp that
actually failed. The parent was being told the wrong error. Save execvp's
errno first. The parent side priming write is checked now too rather than
being assumed to work.

maps.c, -Wvla: `char filename[len]` where len is getline's buffer
capacity, so the size came from the longest path the target process has
mapped and it landed on the stack, once per line, memset in full every
time. One heap buffer that grows with the line buffer instead.

Same regions found and same match list as before on the same target, gcc
and clang both build with no warnings, 23/23 pass.
The build badge pointed at travis-ci.org and there has been no
.travis.yml for a long time, so it rendered as nothing. Points at the
Actions workflow now.

Adds a scan speed section covering `option threads` and what it is worth,
with the measured table rather than a claim. Also says plainly that
process_vm_readv is only a few percent end to end even though the call
itself is much faster, because the scan is limited by the comparison work.

BASELINE.md gets the after numbers next to the before ones, including
the bit where syscalls dropped 30x and time only dropped 4x, and a note
that the apparent threads=1 regression was two orphaned scanmem
processes eating the machine rather than anything in the code.
Adding clang to the matrix turned up that scanmem built with clang at -O1
or higher scans wrong. It finds 3 matches where gcc finds 102, on the same
target, having read the right memory, parsed the right value and picked
the right routine.

Not caused by anything in this branch. Upstream 0375cc0 fails the same way
once an asserting suite is pointed at it. Nothing ever was: the old suite
checked that scanmem exited zero and CI only built with gcc, so a compiler
that silently breaks scanning had nothing to trip over.

Ruled out: strict aliasing, vectorisation, signed overflow, and the extern
inline definitions, none of which change it. -fno-inline recovers part of
it, 4 of 23 checks to 16, so inlining is exposing undefined behaviour
rather than being the bug.

Left in the matrix as continue-on-error rather than dropped, so it stays
visible. gcc and the sanitizer job still gate the run. Written up in the
man page and in bench/BASELINE.md with a reproducer.
…ours

configure~ is an autoconf backup file. It got committed by a careless
`git add -A` in the build flags commit and should never have been in the
tree. Removed and .gitignore now covers backup files, which it did not,
it only listed `configure`.

Two runs on main sat in apt for six hours each before being cancelled.
Those were the old workflow, which is already gone, but nothing stopped a
hung step from eating the whole default budget, so the jobs get
timeout-minutes now: 30 for the builds, 45 for sanitizers and coverity.

apt-get instead of apt, since apt prints its own warning about not having
a stable interface for scripts, plus DEBIAN_FRONTEND=noninteractive and a
retry count so a slow mirror fails instead of hanging.

Left the package list alone. Trimming recommends would be a behaviour
change I cannot verify without pushing, and the point here was the hang.
The maps line was read with %x into an int for the file offset and %u
into an int for the inode. Both of those are unsigned long in what the
kernel writes, and an inode number goes past INT_MAX routinely, so the
conversion was wrong even though nothing downstream reads either field.

This is the second half of CyberShadow's #455. The first half was the
VLA on the stack, which is already fixed in this branch, differently:
that one keeps a heap buffer that grows with the line rather than a
fixed PATH_MAX array, so there is no path length ceiling and no memset
of a 4KB buffer for every line of every scan.

#455 was closed unmerged because the commit was authored by a tool
rather than a person, and the author said to take it from here.

Co-authored-by: CyberShadow <160894+CyberShadow@users.noreply.github.com>
Closes #456. ToyKeeper asked for locks that only allow a value to move one
way, health that can drop but never be refilled by the game, ammo that can
go up but not down. Their patch was against Debian's 0.17-5 which still had
a lockflag column. Current main lost it at some point, all that survived is
the LOCK_FLAG_TYPES constant and a cheatlist_toggle_lock_flag_cb that threw
the edit away, so this is a reimplementation rather than their diff applied.

Flag lives at model index 6 instead of index 0 like the old one did, which
keeps every existing column index and every already saved cheat list file
working. Loader defaults rows with only 6 fields to '='.

'=' is the old behavior, write the value every tick. '+' and '-' read first
and only push back when the game moved the value the wrong way, so the row
display has to follow the value it adopted or the next tick compares against
a stale number. bytearray and string have no ordering, they take the plain
lock whatever the flag is set to.

Tested with a harness that binds the real data_worker, add_to_cheat_list and
read/write_value to a fake memory backend and runs the real
misc.treeview_append_column combo wiring under Xvfb. 24 checks: both
directions on int32/float32/uint64/negative int64, blocked vs kept moves,
no redundant write when already satisfied, no display stomp mid-edit,
bytearray/string fallback, 6-field file load. Not driven through a real GTK
window by hand, and not run against a live target process.
Refs #450, #435, #427. Not a fix for any of them, read on.

CLIPBOARD was built at module scope but used in exactly one place, the copy
address menu item. That made line 55 the first thing in the program to touch
the display, which is why both #450 and #435 have their traceback landing
there rather than anywhere meaningful. Reproduced with DISPLAY and
WAYLAND_DISPLAY unset:

  Gtk-CRITICAL: gtk_clipboard_get_for_display: assertion 'display != NULL' failed

emitted on plain import, exit status still 0, CLIPBOARD left holding
something unusable. Now it is fetched when copy is actually clicked and
returns None if there is no display, so copy goes quiet and startup does not.

Second half: check for a display in main and say why it is missing. pkexec
does not carry x11/wayland authorization over to root, so this fails for root
while working fine for your own user, and nothing in the old output said so.
Prints the cause and the xhost workaround, exits 1.

This does not make it run under pkexec without display authorization. Nothing
here forwards auth, it only turns a gtk assertion into a sentence. #427 still
tracks the real display fix and #416 the pkexec dependency.

Checked: import with no display is now silent, running with no display prints
the message and exits 1, get_clipboard() returns None, and under Xvfb the
clipboard still round trips a copied address. Not tested under an actual
pkexec elevation.
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.

1 participant