Skip to main content

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/// [`release_free_memory`] when dropped: bound at the top of a conversion's
62/// scope (`let _trim = TrimOnDrop;`), the trim runs however the scope ends —
63/// return, early `?`, panic unwinding — after the buffers declared in it are
64/// gone. The bindings put one at every entry point that converts (#871:
65/// Node's `Pipeline.convertAsync` path had no trim, and RSS between
66/// documents climbed 950 → 1,566 MiB over five PDFs).
67#[must_use = "bind it for the scope (`let _trim = TrimOnDrop;`); `let _ =` drops it at once"]
68pub struct TrimOnDrop;
69
70impl Drop for TrimOnDrop {
71    fn drop(&mut self) {
72        release_free_memory();
73    }
74}
75
76/// Return the free memory glibc still holds in its arenas to the OS —
77/// after a conversion, once its buffers are dropped.
78pub fn release_free_memory() {
79    #[cfg(all(target_os = "linux", target_env = "gnu"))]
80    // SAFETY: `malloc_trim` only releases free memory; it touches no live
81    // allocation.
82    unsafe {
83        libc::malloc_trim(0);
84    }
85}