Skip to content

feat(server): add --idle-unload-ms and pre-load memory guard - #306

Merged
0xShug0 merged 7 commits into
0xShug0:mainfrom
gqf2008:feat/server-memory-guard
Aug 26, 2026
Merged

feat(server): add --idle-unload-ms and pre-load memory guard#306
0xShug0 merged 7 commits into
0xShug0:mainfrom
gqf2008:feat/server-memory-guard

Conversation

@gqf2008

@gqf2008 gqf2008 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

On a 16GB Apple Silicon machine, a long-running batch transcription loaded model after model with no eviction or idle unload, exhausting unified memory and surfacing as kIOGPUCommandBufferCallbackErrorOutOfMemory + HTTP 500s. This PR gives audiocpp_server two server-side memory controls that complement the existing max_loaded_models (#298).

Changes

  1. --idle-unload-ms <ms> (idle_unload_ms in server.json, default 0 = disabled): a background thread unloads every resident non-busy model once the server has gone that long without a model load/run. The next request reloads lazily. Replaces the previous external Python monitor (which polled the log mtime and required --log trace spam); no wrapper process needed.
  2. --min-free-memory-mb <mb> (min_free_memory_mb in server.json, default 512, 0 disables the extra headroom): before every lazy load, estimate the model footprint (weights, including session auxiliary files and directory trees, × 1.5 + 128 MiB floor) and refuse with HTTP 503 insufficient_memory when estimate + headroom does not fit the free host memory (Windows GlobalMemoryStatusEx / Linux MemAvailable / new macOS Mach VM stats) or the backend device memory (ggml_backend_dev_memory, CUDA/HIP/Vulkan/Metal; CPU skips the device check).
  3. The load path is serialized even when max_loaded_models = 0, so concurrent lazy loads cannot both pass the pre-check.
  4. macOS implementation of engine::core::available_host_memory_bytes().

Verification

  • Local macOS (Metal): build + server_config_test passed; smoke tests for 503 refusal on low host/GPU memory, normal load, and idle auto-unload (15 s timeout).
  • Cross-platform CI (new workflow in this PR): audiocpp_server build + server_config_test passed on ubuntu-24.04 / windows-2022 / macos-14.
  • Config unit tests added for the new fields (defaults, overrides, negative rejection).
  • Independent code review performed before submission; all blocking findings fixed.

Notes

  • --idle-unload-ms and --min-free-memory-mb both default to safe values (0 / 512) so existing deployments see no behavior change unless configured.
  • CUDA note: after unload, ggml pooled memory may not be returned to the driver immediately; Metal/Vulkan release buffers directly. Consider trim_backend_pools on the unload path as a follow-up.
  • On directory-style model paths the estimate sums the tree with depth/file limits.

The 5-minute idle unload used to live in an external Python monitor inside
the audio-server wrapper, which polled the server log mtime and called
/v1/tasks/unload_all_models. Move it into audiocpp_server itself:

- new ServerConfig field idle_unload_ms (default 0 = disabled), parsed from
  server.json and overridable via --idle-unload-ms
- a background thread unloads every resident non-busy model once the server
  has been idle that long without a model load/run; the next request reloads
  lazily

This drops the log-mtime heuristic (which required --log trace spam) and
removes the need for the external Python wrapper.
Before every lazy model load, estimate the model's resident footprint
(weights plus runtime overhead) and compare against free host memory and,
for GPU backends, the backend device's free memory. Refuse the load with
HTTP 503 insufficient_memory when estimate + configured headroom does not
fit, instead of exhausting the machine (the previous failure mode was
kIOGPUCommandBufferCallbackErrorOutOfMemory after models accumulated on a
16GB Mac).

- add ServerConfig.min_free_memory_mb (default 512 MiB headroom), parsed
  from server.json and overridable via --min-free-memory-mb
- add a macOS implementation of available_host_memory_bytes() using Mach VM
  stats (free + inactive + purgeable pages); Linux/Windows were already
  covered
- add InsufficientMemoryError, mapped to 503 insufficient_memory
Independent review found no criticals; fix the actionable findings:
- --min-free-memory-mb help text now matches the actual 512 MiB default
- estimate_model_memory_bytes() sums directory-style model trees (with
  depth/file limits) instead of counting only regular files, so directory
  models are no longer estimated as 0
- the model load path is serialized even when max_loaded_models is 0, so
  concurrent lazy loads cannot both pass the memory pre-check
- expose engine::core::ensure_backends_loaded() and call it before the GPU
  memory query so the very first load actually runs the device check
- the idle-unload thread now wakes on shutdown in <=250ms slices instead of
  waiting out a full poll interval
- document idle_unload_ms / min_free_memory_mb in app/server/README.md and
  example.json
- add server_config_test coverage for the new fields (defaults, overrides,
  negative rejection)
Run audiocpp_server build + server_config_test on ubuntu/windows/macos to
prove the idle-unload and pre-load memory-check changes compile and behave
on all three desktop platforms. GPU backends are off here (the changes query
memory through the backend-agnostic ggml_backend_dev_memory; existing
workflows already cover CUDA/Vulkan/Metal builds).
The Linux build step used nproc and its condition also matched macOS, so
macOS ran both build steps and the nproc one stalled. Scope the nproc step
to Linux and let macOS use sysctl -n hw.logicalcpu.
@gqf2008

gqf2008 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Hi! This is my first contribution to this repo, so the workflow runs are waiting for maintainer approval (marked action_required). Could you approve the runs when you get a chance?

For reference, the same matrix already passed on my fork (ubuntu-24.04 / windows-2022 / macos-14, audiocpp_server build + server_config_test), and a full code review was done before submitting. Thanks!

@0xShug0

0xShug0 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

@gqf2008 Thanks! I'm glad the Metal backend is starting to get more attention. Will review the PR this morning.

@0xShug0

0xShug0 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

@gqf2008 Some issues during testing:

  1. min_free_memory_mb changes the default behavior because model loading always calls ensure_model_fits_memory(). Existing configs that never opted into this feature may see surprising load failures.

  2. The estimator recursively sums the whole model directory. This can overestimate packages that contain multiple GGUFs or variants, even when a real run would use only one selected GGUF/package choice.

It can also mask the real loader error. I validated this with a multi-GGUF Chatterbox directory:

  • Main, same directory load: 500: model directory contains 2 GGUF files...
  • PR, same directory load: 503 insufficient_memory
    • estimated 8.27 GiB + 50000 MiB headroom exceeds available host memory
  • PR with direct Q8 file under the same config: 200 OK

So the direct selected file can load, but the directory path is rejected earlier by the new memory estimator.

  1. Relative option paths are resolved against model.path.parent_path(), which is correct when model.path is a file, but likely wrong when model.path is a directory.

  2. idle_unload_ms currently appears to measure from request start/load rather than request completion. With idle_unload_ms=1000, a longform Chatterbox request took ~34.6s, and the server unloaded the model immediately after the response with:
    [server] idle 35721 ms: unloaded 1 model(s)
    So long requests can make the model look idle as soon as they finish.

  3. Lazy loads are now globally serialized through model_load_mutex_, even when max_loaded_models == 0. It can reduce concurrency for unrelated first-load requests. E.g.,

Same two concurrent /v1/models/load requests: Chatterbox Q8 and F16.

Main:

WALL_MS=579
TIME_A=0.534779
TIME_B=0.574533

PR:

WALL_MS=1067
TIME_A=0.526078
TIME_B=1.063086

Address 0xShug0's review on 0xShug0#306:

1. Make the memory guard opt-in: min_free_memory_mb now defaults to 0 and 0
   disables ensure_model_fits_memory entirely, so existing configs that never
   opted in see no load-behavior change.
2. Stop over-summing directory models and stop masking the real loader error.
   estimate_model_memory_bytes now estimates only what the loader will read:
   a single file, the one GGUF a directory selects (find_directory_gguf), or a
   full safetensors/HF tree. A directory with several GGUFs and no model.gguf
   is ambiguous, so the guard estimates nothing and the loader's own
   "contains N GGUF files" error surfaces instead of a misleading 503.
3. Resolve relative session-option paths against the model directory when
   model.path is a directory (parent_path only when it is a file).
4. Measure idle time from request completion, not start: run_model and
   run_streaming_model_impl now stamp last_activity_ms_ on completion, so a
   long inference is not unloaded the moment it returns.
5. Only serialize lazy loads through model_load_mutex_ when a guard needs it
   (max_loaded_models > 0 or min_free_memory_mb > 0); with both off, unrelated
   first-load requests keep their original concurrency.

Verified on macOS (Apple M4): audiocpp_server + server_config_test build and
pass; startup smoke shows a single-GGUF dir estimates just the selected file
(10.83 GiB -> 503 when guard on), and an ambiguous multi-GGUF dir surfaces the
real "contains 2 GGUF files" loader error with the guard both on and off.
Brings in c79e588 (tag-driven release pipeline, CI/docs only) and re-triggers
upstream CI; the prior Nix (vulkan) failure was a flaky ggml-vulkan shader-gen
link error (same tree builds green on vulkan on the fork).
@0xShug0

0xShug0 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

@gqf2008 Thanks a lot! I’m going to merge the PR. There is still one minor issue, but it’s not a blocker. A follow-up PR would be appreciated.

For issue (2)

My test:
Create a dummy directory containing two .gguf files:

With min_free_memory_mb: 0, the real loader error surfaces correctly:

500
model directory contains 2 GGUF files...

With min_free_memory_mb: 1000000, the PR still returns the memory guard error first:

503 insufficient_memory
estimated 0.12 GiB + 1000000 MiB headroom exceeds available host memory

@0xShug0
0xShug0 merged commit 1aa9a73 into 0xShug0:main Aug 26, 2026
9 checks passed
@gqf2008

gqf2008 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@0xShug0 Thanks for the thorough testing — all five points are addressed in 0082244 (now on the PR head):

1. Guard is now opt-in. min_free_memory_mb defaults to 0, and 0 now disables ensure_model_fits_memory entirely (not just the headroom). Configs that never opted in see no load-behavior change.

2. Directory estimation no longer over-sums or masks the loader error. The estimator now measures only what the loader will read: a single file; the one GGUF a directory selects via find_directory_gguf (model.gguf or the sole *.gguf); a no-GGUF directory (safetensors/HF checkpoint) as a whole tree; and a multi-GGUF directory with no model.gguf contributes nothing to the estimate, so the loader's own contains N GGUF files error surfaces instead of a 503. I reproduced your scenario with a 2-GGUF dir: before → 503 insufficient_memory; now → model directory contains 2 GGUF files: … found: …, with the guard both on and off. A single-GGUF dir still estimates just the selected file (e.g. 10.83 GiB → 503 when the guard is on).

3. Relative option paths now resolve against model.path when it's a directory, and parent_path() only when it's a file.

4. Idle clock measures from completion. run_model and the streaming path stamp last_activity_ms_ when a request finishes, so a long inference no longer reads as idle the moment it returns (your idle_unload_ms=1000 / 34.6 s case).

5. Lazy loads serialize only when a guard needs it. model_load_mutex_ is taken only when max_loaded_models > 0 or min_free_memory_mb > 0; with both off, unrelated first loads run concurrently again (your Q8+F16 case).

CI is green across the board (Linux cpu/vulkan, Nix cpu/vulkan, Windows, macOS, and the memory-guard build+test matrix). The earlier Nix (vulkan) failure was a flaky ggml-vulkan shader-gen link error unrelated to this PR — the same tree builds green on vulkan on my fork. Happy to adjust anything further.

@gqf2008

gqf2008 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for merging! The minor follow-up on issue (2) is addressed in #308: an ambiguous multi-GGUF directory now reports an indeterminate footprint, so the guard skips it and the loader's own contains N GGUF files error surfaces regardless of the configured headroom (verified with min_free_memory_mb: 1000000).

cunba-ai pushed a commit to cunba-ai/audio.cpp that referenced this pull request Aug 26, 2026
Merge of PR #24 (conflict only in .gitignore: kept fork rules plus
upstream's audio-vtest-*). Brings AudioSR (speech-to-speech
enhancement, existing task kind), ControlFoley, FireRed
(TTS3/Audio + reusable speech modules: s3_tokenizer, HiFi-GAN),
MiDashengLM-Gen decode lifecycle fixes, server --idle-unload-ms +
pre-load memory guard (0xShug0#306), tag-driven prebuilt release CI (0xShug0#286),
and Arena comparison WebUI. Shared-module changes verified additive
(Conv3d new; qwen_causal_decode sliding_window defaults to legacy
path); upstream did not touch BackendWeightStore, so fork's
kMetadataPoolBudget over-commit fix stays intact. fork_regression
9/9 green, loader catalog in sync.

Generated-by: zcode
0xShug0 pushed a commit that referenced this pull request Aug 26, 2026
…ate (#308)

* fix(server): skip memory guard when the model footprint is indeterminate

Follow-up to #306 per maintainer review: a model directory holding several
GGUFs and no model.gguf is ambiguous, and the loader rejects it with its own
"contains N GGUF files" error. The estimator already contributes no weights
for such a directory, but ensure_model_fits_memory still compared the
remaining fixed floor plus the configured headroom against free memory, so a
large headroom (e.g. min_free_memory_mb=1000000) still answered 503 and masked
the real loader error.

estimate_model_memory_bytes now returns nullopt for that ambiguous case, and
ensure_model_fits_memory skips the guard entirely when the footprint is
indeterminate: the load can never allocate anyway, so the loader's error
surfaces no matter how large the headroom is. Determinate footprints (single
file, selected GGUF, safetensors/HF tree) still guard as before.

Verified on macOS: ambiguous 2-GGUF dir with min_free_memory_mb=1000000 now
fails with the loader's "contains 2 GGUF files" error (was 503); the same
headroom on a single-GGUF dir still 503s; guard-off behavior unchanged;
server_config_test passes.

* fix(server): harden model memory estimation and add regression tests

Code-review follow-up to the ambiguous-directory skip:

- extract estimate_model_memory_bytes into app/server/model_memory.[h|cpp]
  so the estimator is unit-testable instead of a private ServerState member
- list the directory once, mirroring the loader's selection: model.gguf
  wins, the sole *.gguf is used alone, and several GGUFs without
  model.gguf stay ambiguous
- ignore files whose size cannot be read instead of folding the
  file_size failure value into the sum
- log when the guard skips an indeterminate model instead of failing
  silently: family-specific layouts (e.g. minimax_music3) may load such
  a directory successfully, so a skipped guard is worth surfacing
- fix the --min-free-memory-mb help text and README to describe the
  opt-in default and the skip behavior
- add estimator tests to server_config_test: single file, sole GGUF,
  model.gguf disambiguation, ambiguous directory, checkpoint tree, and
  relative aux resolution

* fix(test): avoid Windows-reserved directory name in estimator tests

"aux" is a reserved DOS device name, so creating .../aux under the temp
root throws on Windows and fails server_config_test there. Rename the
test directory to "sidecar".
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