Memory management
Changed in version 1.5.
Siril routinely processes sequences whose combined size dwarfs the machine's
RAM, so almost every operation has to answer the same question up front: how
much memory may I use, and therefore how many images may I hold at once? This
chapter describes how Siril measures available memory on each OS, how it turns
that into a budget, how the sequence workers split that budget into a degree of
parallelism, and the allocation and fits-lifecycle conventions you are
expected to follow.
For who runs on which thread see Threading; for the workers that call the memory machinery on your behalf see Generic workers; for the per-OS quirks referenced below see OS differences.
The budget model
The user-facing budget is one number, in megabytes, returned by
get_max_memory_in_MB() in src/core/OS_utils.c. It is driven by a
single preference, com.pref.mem_mode, an anonymous enum defined in
src/core/settings.h with exactly two members:
RATIO(the default) — the budget iscom.pref.memory_ratiotimes the currently available memory reported byget_available_memory(). Because it tracks live availability, this mode adapts to whatever else the machine is doing.AMOUNT— the budget is a fixedcom.pref.memory_amountgigabytes, converted to MB (memory_amount * 1024). This ignores what is actually free, so it is the user's promise that the memory exists.
On a 32-bit build the address space, not the RAM, is the hard limit. After
computing the budget, get_max_memory_in_MB() checks sizeof(void *) == 4
and, if so, clamps the result to 1900 MiB, logging "Limiting processing to
1900 MiB allocations (32-bit system)". Treat this as the ceiling on any single
run's working set on those platforms.
Everything downstream — the sequence parallelism formula, the writer queue sizing, the per-image pre-flight checks — is expressed against this one budget, so a single preference change scales the whole application at once.
Measuring available memory per OS
get_available_memory() returns bytes currently available and is the most
platform-dependent function in the codebase. Each branch is a separate
implementation with its own caveats; when you touch it, read the branch for
your platform, because "available" means something different on each.
Linux (and Cygwin)
Two sources, checked in order:
cgroups limits.
get_available_mem_cgroups()first resolves the process's cgroup via/proc/self/cgroup(find_cgroups_path()distinguishes cgroups v1 from v2 by the empty controller field of the single v2 line), then reads the memory limit. For v1 it triesmemory.soft_limit_in_bytesthenmemory.limit_in_bytes; for v2 it triesmemory.low,memory.high,memory.maxunder/sys/fs/cgroup. The "available" figure it returns is the limit minus the process's own current usage (fromget_used_RAM_memory(), which reads/proc/self/statm). This is what makes Siril behave inside a container or a systemd slice that would otherwise let it read the host's full RAM./proc/meminfo. If no cgroup memory controller is found, Siril parses
/proc/meminfoforMemAvailable:, falling back toMemFree:on kernels older than 3.14 or on WSL2 whereMemAvailableis absent. The value is cached and re-read at most once per second.
macOS
host_statistics64(HOST_VM_INFO64) plus sysctl(HW_MEMSIZE). Siril
computes availability two ways and returns the smaller: (1) free +
purgeable + external (file-cache) pages, and (2) physical memory minus
genuinely used pages (internal minus purgeable, plus wired and compressor
pages). Counting purgeable memory as available is deliberate — the kernel will
reclaim it under pressure — and taking the smaller of the two estimates is the
conservative choice.
Windows
GlobalMemoryStatusEx(), and here the subtlety matters. Siril starts from
ullAvailPhys (free physical RAM) but then takes the minimum of that and
ullAvailPageFile (remaining commit charge). Windows does not overcommit: an
allocation fails once the commit charge reaches RAM plus pagefile, so the
binding constraint is the commit headroom, not free physical memory. On a
machine with a small or disabled pagefile the commit headroom can be lower
than free RAM, and a budget derived from ullAvailPhys alone would promise
memory the OS then refuses to hand over — an allocation failing mid-processing
instead of an honest upfront "not enough memory" decision. This one line is the
fix for a whole class of Windows-only out-of-memory reports.
BSD
get_available_memory() parses avail memory out of
/var/run/dmesg.boot once and caches it. As the source comment
flags, this is the amount available at boot, not at runtime, so on BSD the
budget is effectively static. Treat it as a known limitation rather than a
measurement.
The sequence parallelism formula
Sequences are processed frame-by-frame, and the budget decides how many frames
may be in flight at once. The core computation lives in
compute_nb_images_fit_memory() in src/io/sequence.c (delegating to
compute_nb_images_fit_memory_from_dimensions()), with a
compute_nb_images_fit_memory_from_fit() variant for a single fits.
The per-image footprint is rx * ry * layers * bytes-per-sample, where
bytes-per-sample is sizeof(float) when the frame is float or force_float
was requested, else sizeof(WORD). A factor argument scales the
dimensions for operations that enlarge their output (registration upscaling,
drizzle), so the function reports both an original and a scaled MB figure.
The answer is simply max_memory_MB / MB_per_scaled_image — the budget
divided by the scaled footprint. For a variable-size sequence,
get_max_seq_dimension() picks the largest included frame's dimensions, so
the estimate is safe for every frame.
seq_compute_mem_limits() in src/core/processing.c wraps this for the
generic sequence worker. Without OpenMP the parallelism is 1. With OpenMP the
result is capped at com.max_thread. If the footprint does not fit even once,
it returns 0, and the operation is rejected with a "not enough memory" error
naming the per-image requirement and the memory considered available.
When the default is wrong: compute_mem_limits_hook
The default formula assumes the only memory in play is the input plus the
scaled output. That is false for any operation whose peak footprint is larger
— typically because it allocates intermediate buffers. When that is the case
you must supply args->compute_mem_limits_hook on your struct
generic_seq_args (see Generic workers); the worker calls
it in preference to seq_compute_mem_limits().
Two worked examples, both of which enumerate their intermediates explicitly:
Registration,
apply_reg_compute_mem_limits()(with its accounting inapply_reg_compute_mem_consumption()) insrc/registration/applyreg.c. Beyond input + scaled output it adds the two 32-bit remap maps used for undistortion or drizzle, the drizzle output counts image, and — the interesting part — the transient OpenCV overhead of colour interpolation, wherefits_to_bgrbgrandMat_to_imagebriefly hold roughly2 × origor2 × dest − originstead of the naiveorig + dest, plus a guide image and an 8-bit mask copy when clamping is on.Blending / mask creation,
compute_mask_compute_mem_limits()insrc/stacking/blending.c, which likewise adds the 8-bit and 32-bit layer copies that flow in and out of OpenCV to the base image cost.
If your operation transforms via OpenCV, upscales, or keeps more than one working copy alive at the peak, write a hook and account for every buffer that is live simultaneously. Underestimating here is exactly how you produce an OOM that the pre-flight was supposed to prevent.
The writer side (for_writer)
Both seq_compute_mem_limits() and every compute_mem_limits_hook take a
for_writer boolean. When FALSE the return value is the number of frames
processed in parallel (the threads). When TRUE it is instead the number of
finished frames that may sit queued in the sequence writer waiting to be flushed
to disk — computed from the budget left over after the processing threads have
taken their share, and capped at com.max_thread * 3.
seq_prepare_writer() feeds this number to
seqwriter_set_max_active_blocks() in src/io/seqwriter.c, which blocks
producers once that many output blocks are active. This back-pressure is what
keeps a fast multi-thread stage from filling RAM with results faster than the
writer can drain them.
Single-image operations: mem_ratio
For one-shot operations run through generic_image_worker there is no
frame count to compute; instead struct generic_img_args carries a
float mem_ratio — the operation's peak memory expressed as a multiple of
the image size. A simple in-place filter is ~1; something that keeps a full
extra working copy is ~2, and so on.
When mem_ratio > 0 the worker runs a pre-flight before doing any work, in
default_img_mem_hook() (src/core/processing.c): it computes
mem_ratio * rx * ry * channels * bytes-per-sample and compares it against
get_available_memory(), aborting with "Not enough memory for operation" if
it does not fit. generic_mask_worker has the analogous
default_mask_mem_hook(), sized on the mask's bitpix (falling back to
4 bytes per pixel as a worst case when no mask is attached yet). Setting
mem_ratio to 0 skips the check entirely — do that only for operations whose
footprint is genuinely negligible.
Allocation conventions
Pair your allocator and freer. Memory from malloc/calloc is released
with free; memory from GLib's g_malloc/g_new is released with
g_free. Never cross the streams. Most string and GLib-object handling uses
the g_* family; raw pixel and scratch buffers typically use plain
malloc/calloc.
siril_malloc / siril_calloc / siril_free
(src/core/siril_alloc.c, header src/core/siril_alloc.h) are a
third family used specifically for large buffers. On Windows they are backed by
VirtualAlloc (MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) and freed
with VirtualFree/MEM_RELEASE; siril_calloc relies on VirtualAlloc
returning zeroed pages and also guards against a multiplication overflow in
num * size. On every other platform they are thin wrappers over
malloc/calloc/free. The intent is to route big single allocations
around the Windows CRT heap, which can fail or fragment on large requests where
VirtualAlloc succeeds. In the tree today they back exactly the large
transform buffers that motivated them — the stacking data pool and scaled-mask
buffers in src/stacking/median_and_mean.c, the drizzle and undistortion
remap maps in src/drizzle/cdrizzlemap.c and
src/registration/distorsion.c, and the drizzle output buffers in
src/registration/applyreg.c. A block obtained from siril_malloc
must be released with siril_free and no other freer, because on Windows
the two use VirtualAlloc/VirtualFree rather than the CRT heap.
Note
siril_realloc does not exist — it is commented out in
src/core/siril_alloc.c because VirtualAlloc has no in-place grow.
If you need to resize a siril_malloc block, allocate a new one and copy.
Report allocation failures with PRINT_ALLOC_ERR — the macro from
src/core/siril.h that prints Out of memory in <func> (<file>:<line>).
Use it on the failure path of any allocation you check.
Give worker-argument structs a destructor. By convention the user
pointer on struct generic_img_args (and the sequence and mask equivalents)
must have a destructor function pointer as its first member, which the worker
calls when it frees the args. This is the standard cleanup contract for the
generic workers — see Generic workers.
fits memory and the statistics cache
A fits (defined in src/core/siril.h) holds pixels in one of two
mutually exclusive buffers, selected by its type field: data (with
per-layer pointers pdata[3]) for DATA_USHORT 16-bit WORD pixels, or
fdata (with fpdata[3]) for DATA_FLOAT pixels. Only one is allocated
at a time; the pdata/fpdata arrays are not separate allocations but
offsets into the single buffer, one per layer. Do not assume both are present.
Converting between the two — for example honouring com.pref.force_16bit to
avoid 32-bit pixel depth — goes through fit_replace_buffer() in
src/io/image_format_fits.c, which swaps the buffer, updates type,
and crucially calls invalidate_stats_from_fit().
That brings us to the classic bug. A fits caches per-layer statistics in its
stats array (imstats). Any code that modifies pixels in place must
call invalidate_stats_from_fit() (src/algos/statistics.c)
afterwards, or later consumers will read stale mean/median/noise values from the
cache and silently do the wrong thing. Forgetting this is a recurring class of
subtle regression; if you write to a buffer, invalidate the stats.
Finally, an image may carry a mask (mask_t in src/core/siril.h),
a separate data buffer whose per-pixel size follows its own bitpix. It
is an additional allocation to account for in any memory hook that runs on
mask-active images.
Disk space
Sequence-producing operations write intermediate and output files, so they must
also check disk, not just RAM. test_available_space()
(src/core/OS_utils.c) compares a requested byte count against the free
space in the working directory com.wd (via find_space(), itself an
OS-specific statvfs/statfs/GetDiskFreeSpaceExW implementation).
Call it before writing a sequence. Two behaviours are worth knowing:
If FITS compression is enabled (
com.pref.comp.fits_enabled) the check is softened: because compressed output is smaller than the raw estimate, a shortfall is downgraded from a hard error to a warning, and only escalates in tone once the requested size exceeds free space by more thanMAX_COMP_FREESPACE_RATIO(3×). Without compression, insufficient space is a hard error that stops the operation.The swap/temporary directory used for sequence work is a separate preference,
com.pref.swap_dir;update_displayed_memory()reports free space for bothcom.wdandswap_dirin the GUI.
See also
Generic workers — the workers that call the budget, the hooks, and the args-destructor contract.
Threading — how
com.max_threadand the parallelism figure relate.OS differences — the per-OS behaviour behind
get_available_memory()andfind_space().