Files
RetroArch/tools/stack_budget.py
T
LibretroAdmin a86d86a22c HAVE_RGIF: GIF images through image_texture, image_transfer and the tasks
rgif now loads GIFs wherever RetroArch loads images: thumbnails and
backgrounds through task_image, the image viewer core, the file browser
and the image_texture/image_transfer layer underneath them all.

rgif:
- the image_transfer interface the other decoders have:
  rgif_set_buf_ptr, rgif_set_avail, rgif_header_ready and
  rgif_process_image, giving a 32-bit texture of its own, ARGB32 or
  ABGR32 in native byte order
- RGIF_ABGR, for ABGR32 on big-endian targets
- a file not all read yet gives IMAGE_PROCESS_WAIT and is not touched;
  the image is decoded in one call once it is all in

Wiring, as HAVE_RTGA is wired:
- IMAGE_TYPE_GIF (image.h) and NBIO_TYPE_GIF (task_file_transfer.h),
  both appended so no other value moves
- image_texture_get_type: .gif
- image_transfer: new, free, start, is_valid, set_buffer_ptr, process,
  set_avail; the support matrix gains a GIF column
- task_image and task_file_transfer: GIF is an image task
- FILE_TYPE_GIF ("gif", "GIF"): opened as image content
- the file browser's image extensions, the image viewer core's
  extensions, the System Information list ("GIF (RGIF)") and
  --features ("RGIF"); SUPPORTS_RGIF in config.features.h
- griffin.c, Makefile.common (rgif.o), configure (HAVE_RGIF=yes), and
  next to HAVE_RTGA in every platform Makefile, the Android, Apple,
  MSVC, UWP and QNX projects and the CI compile matrix
- tools/settings_check.py and tools/stack_budget.py check with it

Also: menu_displaylist's image viewer extension list wrote "tga"
without moving its length on, so "webp" and "dds" landed on top of it
and TGA dropped out of the list; it moves on now.

Tests: rgif_test checks the texture in both byte orders against
rgif_decode, the wait before the whole file is in, and a refused file;
image_decode_fuzz sweeps GIF too (every byte of a small GIF set to
edge values, and every truncation), under ASan and UBSan. The griffin
unity build with every codec, the touched objects in a configured
build, the MinGW/C89 syntax check, the overlap check and the stack
budget pass.
2026-11-10 20:00:24 +01:00

403 lines
18 KiB
Python

#!/usr/bin/env python3
"""Fail on libretro-common functions whose stack frame would not fit a
console thread stack.
The smallest thread stack in the tree is GEKKO's 8 KiB - STACKSIZE in
rthreads/rthreads.c - with 32 KiB on 3DS and PSP and 0x10000 on Vita.
(The floor is GEKKO's.) Nothing warns when a local buffer outgrows them:
it builds everywhere, and overflows only on a target most contributors
cannot build, at whatever moment that code first runs on a task
thread.
That is not hypothetical. config_file_write() carried a 16 KiB stdio
buffer as a local, twice an 8 KiB stack, and is reached from
input_autoconfigure_connect_handler - a task handler, so it runs off
the main thread whenever the task queue is threaded, on the path a
gamepad being plugged in takes.
Frames are measured with -fstack-usage under -DPSP, because
PATH_MAX_LENGTH is 512 on those targets rather than 2048 and a
host-shaped measurement overstates every function that holds a path
buffer - several archive functions look like 6 KiB on a desktop build
and are under 4 KiB there.
The budget is deliberately well under 8 KiB: a frame is not alone on
the stack, it sits under whatever called it.
Two things about how this is measured, both learned the hard way.
A frame size is a property of an ABI, not of the source. The Windows
x64 ABI has the caller reserve 32 bytes of shadow space for every
call, keeps more registers callee-saved, and passes four arguments in
registers where SysV passes six. Every recorded size below therefore
comes out 16 to 192 bytes larger under mingw-w64 than under the
reference toolchain, on identical code. Comparing an absolute
recorded size against a measurement from a different target triple
reports the whole allowlist as regressed and buries anything real in
the noise, so the recorded sizes are only ratcheted when the host is
the reference target; elsewhere they are reported as drift.
The check also only ever sees the code its host compiles, which is
the same blind spot it exists to cover. read_stdin() carried a 5 KiB
INPUT_RECORD array for years inside `#elif defined(_WIN32)`, invisible
to every Linux run of this script. So when a cross-compiler is on
PATH it is used for a second pass, and BUDGET - unlike the recorded
sizes - is enforced on every pass, because a coarse threshold is
robust to a few hundred bytes of ABI overhead in a way that an exact
byte count is not.
"""
import os
import re
import shutil
import subprocess
import concurrent.futures
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INCLUDE = os.path.join(ROOT, 'libretro-common', 'include')
SRC_ROOT = os.path.join(ROOT, 'libretro-common')
# Bytes. Leaves the majority of an 8 KiB stack for callers.
BUDGET = 2048
# The target the sizes in ALLOWLIST were measured on.
REFERENCE_TARGETS = ('x86_64-pc-linux-gnu', 'x86_64-linux-gnu')
# Added to both BUDGET and the recorded sizes when the measuring
# target is not a reference one, so that pure ABI overhead does not
# read as a regression. The largest gap observed between SysV and
# Windows x64 across this tree is 192 bytes; a frame that has really
# grown, by a buffer someone added, clears 256 easily. This is slack
# for a comparison that is already approximate off-reference - it is
# not a raise in the budget, and BUDGET itself does not move.
ABI_SLACK = 256
# Where installed, each compile goes through ccache, which keeps the
# .su with the object: a file unchanged since the last run is not
# compiled again, and most of a run is three passes over unchanged files.
CCACHE = shutil.which('ccache')
# Used for an extra pass when present, to reach code the host's own
# preprocessor throws away. Not required: absent ones are skipped.
CROSS_COMPILERS = ('x86_64-w64-mingw32-gcc', 'i686-w64-mingw32-gcc')
# Frames that exceeded the budget when this check was written, with
# the size measured then.
#
# THE RULE: an entry here is debt, not an exemption. "It is not built
# on a small-stack target" and "nothing reaches it from a task thread"
# are both statements about today, and both have already been wrong
# once - config_file_write() was exempt on exactly that reasoning
# until its caller turned out to be a task handler. Platforms gain
# features, call graphs gain edges, and a frame that is fine now is
# fine only until someone connects it to something. So the question
# for an entry is not "can this overflow today" but "does this need
# to be on the stack at all", and the answer is usually no: the
# fixable ones are a struct or a scratch array that could live on the
# heap or in a state object that is already allocated once.
#
# Fixed under that rule rather than argued about:
# vh_build 16784 -> 400 scratch to the heap
# config_file_write 16432 -> 48 stdio buffer to the heap
# chd_read_header_core_file 57504 -> 32 whole chd_file to the heap
# rd_gen_lengths 12768 -> 352 scratch into struct rdeflate
# rzstd_emit_block 9440 -> 0 FSE tables into encoder scratch
# sha1_calculate 4304 -> 224 mapped view, else heap buffer
# filestream_vscanf 4368 -> 272 scan window to the heap
#
# What is left is mostly codec inner loops (rvp9, rh264, raac, ropus,
# rflac, rzstd, rvorbis) whose working sets are large by nature. Large
# by nature is not the same as necessarily-on-the-stack: most of them
# could hold their scratch in the decoder state, which is allocated
# once per stream. They are recorded rather than fixed because that is
# per-codec work with real regression risk, not because they are safe.
#
# The check is a ratchet: it stops the list growing. Removing an entry
# is always welcome; adding one is a decision someone has to write
# down, and "not reachable on the targets I checked" is not one of the
# reasons that counts.
#
# The value is the size at the time of writing, so a frame that grows
# past its recorded size is caught even while allowlisted. It is the
# size on REFERENCE_TARGETS; see the module docstring for why it is
# not comparable against a measurement from another triple.
ALLOWLIST = {
}
def toolchains():
"""[(cc, target, is_reference)] for every compiler available here.
The host compiler is always first: it is the one that decides
whether a named file failing to compile is an error, and the only
one that may ratchet ALLOWLIST, and then only if its target is a
reference one.
"""
found = []
for cc in ('gcc',) + CROSS_COMPILERS:
try:
r = subprocess.run([cc, '-dumpmachine'], capture_output=True)
except OSError:
continue
if r.returncode != 0:
continue
target = r.stdout.decode().strip()
found.append((cc, target, target in REFERENCE_TARGETS))
return found
def stack_usage(path, workdir, cc='gcc', tag='o'):
"""Compile one TU and return ([(func, bytes)], gcc stderr).
Frames are None when the TU did not build, and the stderr is
returned with it so a caller that named the file itself can say
why rather than skipping it silently.
gcc writes the .su alongside the object named by -o, not alongside
the source, so the name has to be derived from the object. Getting
that wrong is silent: the file simply is not there, every TU
reports no frames, and the check passes while measuring nothing.
It did, until a deliberately oversized buffer failed to trip it.
"""
# gcc runs with cwd=workdir so the .su lands there, which means a
# relative source path would resolve against the temp directory and
# not be found. The tree walk never hit this because SRC_ROOT is
# built from abspath(__file__); a path typed on the command line is
# relative more often than not.
path = os.path.abspath(path)
# @tag keeps one compile's object and .su apart from another's:
# measure() runs several at once.
obj = os.path.join(workdir, tag + '.o')
su = os.path.join(workdir, tag + '.su')
if os.path.exists(su):
os.remove(su)
# The define set has to be at least as wide as a real build, or
# the check measures code no target compiles and misses code every
# target does. HAVE_7ZIP is the case that proved it: without it
# libchdr_chd.c's largest frame is 8320 bytes, with it 57504 - a
# seven-fold difference in shipped code, under a define that wii,
# ctr and every desktop build set.
defs = ['-DPSP', '-DHAVE_COMPRESSION', '-DHAVE_7ZIP',
'-DHAVE_CHD', '-DHAVE_RPNG', '-DHAVE_RJPEG', '-DHAVE_RBMP',
'-DHAVE_RTGA', '-DHAVE_RGIF', '-DHAVE_RWEBP', '-DHAVE_RDDS',
'-DHAVE_RWAV']
if path.replace(os.sep, '/').endswith('rthreads/rthreads.c'):
# Under -DPSP this TU is the native sceKernel backend and only
# the PSP SDK's compiler can see its headers; measure the
# portable pthread lane here and leave the native lanes to a
# cross pass. It holds no path buffers, so the
# PATH_MAX_LENGTH shaping -DPSP exists for does not apply.
defs.remove('-DPSP')
defs.append('-DHAVE_THREADS')
cmd = ([CCACHE] if CCACHE else []) + [cc, '-O2'] + defs + [
'-I' + INCLUDE, '-fstack-usage', '-c', path, '-o', obj]
r = subprocess.run(cmd, cwd=workdir, capture_output=True)
# The .su existing is not proof the compile succeeded: gcc creates
# it and then bails, so a TU that fails to parse leaves an empty
# one behind and reports no frames - measuring nothing while
# looking clean, the same failure the file name once caused. The
# exit status is the thing to trust.
if r.returncode != 0 or not os.path.exists(su):
# Did not compile on this host (platform-specific TU, missing
# dependency). Report it rather than counting it as scanned.
for leftover in (obj, su):
if os.path.exists(leftover):
os.remove(leftover)
return None, r.stderr.decode('utf-8', 'replace')
out = []
with open(su) as f:
for line in f:
parts = line.rstrip('\n').split('\t')
if len(parts) < 2:
continue
name = re.sub(r'^.*:', '', parts[0])
try:
out.append((name, int(parts[1])))
except ValueError:
pass
# one object a TU would otherwise pile up for the whole walk
for leftover in (obj, su):
if os.path.exists(leftover):
os.remove(leftover)
return out, ''
def walk(root):
"""Yield the .c files under root that a console actually builds."""
for dirpath, _, files in os.walk(root):
parts = dirpath.split(os.sep)
# Shipped code only: samples, tools and tests are
# host programs, not console builds, and their
# frames are irrelevant to a PSP thread stack.
if ({'samples', 'tools', 'test', 'tests'} & set(parts)):
continue
for fn in sorted(files):
if fn.endswith('.c'):
yield os.path.join(dirpath, fn)
def sources(args):
"""(path, named) for each TU to measure.
named is True for a file the caller spelled out, and decides what a
failure to compile means. In a walk it means "not this host" and
is skipped, which is right for a platform TU nobody expects to
build here. For a file someone named it means the measurement did
not happen, and skipping it would print a clean result for a file
that was never looked at - the same failure mode as the missing
.su. A directory given as an argument is a walk of that
directory, and its files skip like any other walk.
"""
if not args:
for path in walk(SRC_ROOT):
yield path, False
return
for arg in args:
if os.path.isdir(arg):
for path in walk(arg):
yield path, False
else:
yield arg, True
class Fatal(Exception):
"""A file the caller named could not be measured."""
def measure(cc, is_host, argv, tmp):
"""([(size, name, fn)], scanned, skipped) for one toolchain.
The strictness about a named file that does not compile is the
host compiler's alone. A cross pass is extra coverage over code
the host preprocesses away, so a POSIX-only TU someone named
failing to build under mingw is expected rather than an error -
the host pass has already measured it.
"""
frames = []
scanned = 0
skipped = 0
todo = list(sources(argv))
# Each TU is its own compiler process, so they are run as many at a
# time as there are processors: one after another, the walk had
# grown to several minutes a toolchain. The results are taken in
# the walk's order, so what is reported, and which named file an
# error is about, do not depend on which compile finished first.
with concurrent.futures.ThreadPoolExecutor(
max_workers=max(1, os.cpu_count() or 1)) as pool:
measured = list(pool.map(
lambda it: stack_usage(it[1][0], tmp, cc, 'o%d' % it[0]),
enumerate(todo)))
for (path, named), (found, err) in zip(todo, measured):
named = named and is_host
if named and not os.path.exists(path):
raise Fatal('error: %s: no such file' % path)
# A header compiles to a .gch and yields no .su, which
# would otherwise be reported as "did not compile" with
# nothing to say about why.
if named and not path.endswith('.c'):
raise Fatal('error: %s: not a .c translation unit' % path)
if found is None:
if named:
raise Fatal('error: %s did not compile; nothing was '
'measured for it\n%s' % (path, err.rstrip()))
skipped += 1
continue
scanned += 1
fn = os.path.basename(path)
frames += [(size, name, fn) for name, size in found]
return frames, scanned, skipped
def main(argv):
if any(a in ('-h', '--help') for a in argv):
print('usage: stack_budget.py [FILE.c | DIR ...]\n\n'
'With no arguments, measures every shipped .c under\n'
'libretro-common - what CI runs, and the only form CI\n'
'depends on. With arguments, measures exactly those\n'
'files, and a named file that does not compile is an\n'
'error rather than a skip.\n\n'
'Every cross-compiler in CROSS_COMPILERS that is on PATH\n'
'is measured as well, to reach code this host would\n'
'preprocess away. The sizes in ALLOWLIST are enforced\n'
'only on REFERENCE_TARGETS, since a frame size is a\n'
'property of an ABI; elsewhere they are reported as\n'
'drift.')
return 0
chains = toolchains()
if not chains:
print('error: no C compiler found; the check is not measuring '
'anything', file=sys.stderr)
return 2
over = [] # hard failures
drift = [] # allowlisted, over its recorded size, off-reference
scanned = 0
with tempfile.TemporaryDirectory() as tmp:
for i, (cc, target, is_reference) in enumerate(chains):
try:
frames, n, skipped = measure(cc, i == 0, argv, tmp)
except Fatal as e:
print(e, file=sys.stderr)
return 2
scanned += n
print(' %-24s %-24s %4d TU measured, %3d not built'
% (cc, target, n, skipped))
# Off-reference, every limit is approximate by the width
# of the ABI difference, so both of them move together.
slack = 0 if is_reference else ABI_SLACK
for size, name, fn in frames:
if name not in ALLOWLIST:
# Never recorded at any size, on any target.
if size > BUDGET + slack:
over.append((size, name, fn, target))
elif size > ALLOWLIST[name] + slack:
# Grown past what was recorded. An error on the
# target that recorded it, drift anywhere else.
if is_reference:
over.append((size, name, fn, target))
else:
drift.append((size, name, fn, target))
# A run that measured nothing is a broken check, not a clean tree.
if scanned == 0:
print('error: no translation unit yielded stack data; the '
'check is not measuring anything', file=sys.stderr)
return 2
if not any(ref for _, _, ref in chains):
print('\nnote: no reference target (%s) among the compilers '
'here, so the sizes recorded in ALLOWLIST were not '
'enforced. CI is authoritative for those.'
% ', '.join(REFERENCE_TARGETS))
if drift:
print('\nnote: allowlisted frames more than %d bytes above '
'their recorded size on a non-reference target, which is '
'more than the ABI accounts for:' % ABI_SLACK)
for size, name, fn, target in sorted(drift, reverse=True):
print(' %7d (recorded %d, %+d) %s (%s, %s)'
% (size, ALLOWLIST[name], size - ALLOWLIST[name],
name, fn, target))
if over:
print('\nerror: stack frames over %d bytes, against an 8 KiB '
'thread stack on PSP and GX:' % BUDGET, file=sys.stderr)
for size, name, fn, target in sorted(over, reverse=True):
print(' %7d %s (%s, %s)' % (size, name, fn, target),
file=sys.stderr)
print('\nMove the buffer to the heap, shrink it, or - if it '
'genuinely cannot reach a thread on those targets - add '
'it to ALLOWLIST in %s with the reason.'
% os.path.basename(__file__), file=sys.stderr)
return 1
print('\nstack budget: no frame over %d bytes' % BUDGET)
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))