docling_core/memory.rs
1//! Allocator housekeeping for a process that runs ONNX Runtime or converts
2//! many documents — a server, an agent tool, a batch worker, a binding
3//! (#263, #669, #871).
4//!
5//! A conversion churns hundreds of MB of short-lived buffers (page bitmaps,
6//! ONNX Runtime tensors) across many threads. glibc's allocator keeps what
7//! they free: measured on the MCP server, 8 MB was live after a 9-page PDF
8//! while RSS sat at 0.5–1.2 GB — all of it free memory held in 21 per-thread
9//! arenas, growing call over call. Two causes, two calls:
10//!
11//! - [`pin_mmap_threshold`], once at startup: glibc raises its mmap
12//! threshold (up to 32 MiB) every time a large mapped block is freed, after
13//! which blocks of that size are carved from the arenas, where they
14//! fragment and stay resident. A fixed threshold turns the adjustment off,
15//! so large buffers are mapped and unmapped again. docling-onnx pins it
16//! when it creates the ONNX Runtime environment, so every process that
17//! runs a model has it — the CLI and the bindings too, not only the
18//! servers (#871): the TableFormer decoder allocates a KV cache one token
19//! longer on every step, so a long table frees hundreds of ever-larger
20//! blocks inside a single conversion. With the threshold free to rise
21//! they all landed in the arenas, and on linux/arm64 (ONNX Runtime 1.31)
22//! few of the freed blocks were reused: 4.3 GB of free heap 608 steps
23//! into one table, an OOM under a 6 GiB limit that 2.22.0 converted in
24//! 3.3 GB.
25//! - [`release_free_memory`], after each conversion: hands the free pages
26//! still inside the arenas back to the OS.
27//!
28//! Both are glibc-only (Linux with the GNU C library) and do nothing
29//! elsewhere — musl and the macOS/Windows allocators lack the pattern and
30//! the calls.
31
32/// The fixed mmap threshold [`pin_mmap_threshold`] sets. Any fixed value
33/// stops the growth; measured on a warm MCP server (9-page paper, 4 vCPU,
34/// ONNX arena off) RSS held at ~475 MB with 1, 4 or 16 MiB against 500 →
35/// 750 MB without, and from 4 MiB up the conversions were as fast as
36/// glibc's default — at 128 KiB every ONNX intermediate tensor was mapped
37/// and conversions ran ~8% slower.
38pub const MMAP_THRESHOLD: usize = 4 << 20;
39
40/// Pin glibc's mmap threshold at [`MMAP_THRESHOLD`] for the rest of the
41/// process, unless the operator chose one (`GLIBC_TUNABLES` naming
42/// `glibc.malloc.mmap_threshold`, or `MALLOC_MMAP_THRESHOLD_`). Idempotent;
43/// call it early — blocks the arenas already hold stay there.
44pub fn pin_mmap_threshold() {
45 #[cfg(all(target_os = "linux", target_env = "gnu"))]
46 {
47 let tunables = crate::env::nonempty("GLIBC_TUNABLES").unwrap_or_default();
48 if tunables.contains("glibc.malloc.mmap_threshold")
49 || crate::env::nonempty("MALLOC_MMAP_THRESHOLD_").is_some()
50 {
51 return;
52 }
53 // SAFETY: `mallopt` only changes the allocator's parameters; the
54 // value is a valid threshold (glibc caps it at 32 MiB on 64-bit).
55 unsafe {
56 libc::mallopt(libc::M_MMAP_THRESHOLD, MMAP_THRESHOLD as libc::c_int);
57 }
58 }
59}
60
61/// Return the free memory glibc still holds in its arenas to the OS —
62/// after a conversion, once its buffers are dropped.
63pub fn release_free_memory() {
64 #[cfg(all(target_os = "linux", target_env = "gnu"))]
65 // SAFETY: `malloc_trim` only releases free memory; it touches no live
66 // allocation.
67 unsafe {
68 libc::malloc_trim(0);
69 }
70}