This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new 90f8096ed feat(executor): default to a bounded, auto-sized memory pool 
(#2160)
90f8096ed is described below

commit 90f8096ed340d7e035efdc6e12eff566a8111e6c
Author: Andy Grove <[email protected]>
AuthorDate: Fri Jul 24 05:46:33 2026 -0600

    feat(executor): default to a bounded, auto-sized memory pool (#2160)
---
 ballista/executor/src/config.rs           |  45 +++-
 ballista/executor/src/executor_process.rs | 342 +++++++++++++++++++++++++++++-
 docs/source/user-guide/tuning-guide.md    |  43 ++--
 3 files changed, 401 insertions(+), 29 deletions(-)

diff --git a/ballista/executor/src/config.rs b/ballista/executor/src/config.rs
index 4a7b638d3..2b0ffe608 100644
--- a/ballista/executor/src/config.rs
+++ b/ballista/executor/src/config.rs
@@ -42,6 +42,21 @@ fn parse_memory_pool_size(s: &str) -> Result<u64, String> {
         .map_err(|e| format!("invalid byte size '{s}': {e}"))
 }
 
+/// Parse and validate the `--memory-pool-fraction` value, which must be in
+/// the range `(0.0, 1.0]`.
+fn parse_memory_pool_fraction(s: &str) -> Result<f64, String> {
+    let f = s
+        .parse::<f64>()
+        .map_err(|e| format!("invalid fraction '{s}': {e}"))?;
+    if f > 0.0 && f <= 1.0 {
+        Ok(f)
+    } else {
+        Err(format!(
+            "memory pool fraction must be in (0.0, 1.0], got {f}"
+        ))
+    }
+}
+
 /// Command-line arguments for configuring a Ballista executor.
 ///
 /// This struct is parsed from command-line arguments using clap and contains
@@ -183,15 +198,26 @@ pub struct Config {
         help = "Metric collection policy of this executor instance"
     )]
     pub metric_collection_policy: ExecutorMetricCollectionPolicy,
-    /// Optional total memory budget for the executor. Accepts human-readable
-    /// values like "8GB", "512MiB", or a plain byte count. When set, each
-    /// per-vcore FairSpillPool gets `memory_pool_size / vcores`.
+    /// Total memory budget for the executor. When unset, defaults to
+    /// `--memory-pool-fraction` of the detected host/cgroup memory limit. `0`
+    /// disables the pool (unbounded, no spilling). Accepts human-readable 
values
+    /// like "8GB", "512MiB", or a plain byte count. Split evenly across 
vcores.
     #[arg(
         long,
         value_parser = parse_memory_pool_size,
-        help = "Optional total executor memory budget (e.g. \"8GB\", 
\"512MiB\"). Split evenly across vcores."
+        help = "Total executor memory budget (e.g. \"8GB\"). Unset = fraction 
of detected memory; 0 = unbounded. Split across vcores."
     )]
     pub memory_pool_size: Option<u64>,
+    /// Fraction (0.0, 1.0] of the detected host/cgroup memory limit used for 
the
+    /// auto memory pool when `--memory-pool-size` is unset. Ignored when
+    /// `--memory-pool-size` is given.
+    #[arg(
+        long,
+        default_value_t = 
crate::executor_process::DEFAULT_MEMORY_POOL_FRACTION,
+        value_parser = parse_memory_pool_fraction,
+        help = "Fraction (0-1] of detected memory for the auto pool. Default 
0.70. Ignored if --memory-pool-size is set."
+    )]
+    pub memory_pool_fraction: f64,
     /// Maximum number of sessions whose shared runtime state (object-store
     /// clients, Parquet footer cache) is retained on the executor (LRU). `0`
     /// disables caching.
@@ -247,6 +273,7 @@ impl TryFrom<Config> for ExecutorProcessConfig {
             executor_heartbeat_interval_seconds: 
opt.executor_heartbeat_interval_seconds,
             metric_collection_policy: opt.metric_collection_policy,
             memory_pool_size: opt.memory_pool_size,
+            memory_pool_fraction: opt.memory_pool_fraction,
             session_runtime_cache_capacity: opt.session_runtime_cache_capacity,
             override_execution_engine: None,
             override_function_registry: None,
@@ -264,8 +291,18 @@ impl TryFrom<Config> for ExecutorProcessConfig {
 
 #[cfg(test)]
 mod tests {
+    use super::parse_memory_pool_fraction;
     use super::parse_memory_pool_size;
 
+    #[test]
+    fn parse_fraction_accepts_valid_and_rejects_out_of_range() {
+        assert_eq!(parse_memory_pool_fraction("0.7").unwrap(), 0.7);
+        assert_eq!(parse_memory_pool_fraction("1.0").unwrap(), 1.0);
+        assert!(parse_memory_pool_fraction("0").is_err()); // must be > 0
+        assert!(parse_memory_pool_fraction("1.5").is_err()); // must be <= 1
+        assert!(parse_memory_pool_fraction("banana").is_err());
+    }
+
     #[test]
     fn parse_decimal_suffix() {
         assert_eq!(parse_memory_pool_size("8GB").unwrap(), 8_000_000_000);
diff --git a/ballista/executor/src/executor_process.rs 
b/ballista/executor/src/executor_process.rs
index a48216132..091a94c5f 100644
--- a/ballista/executor/src/executor_process.rs
+++ b/ballista/executor/src/executor_process.rs
@@ -32,7 +32,7 @@ use datafusion_proto::physical_plan::PhysicalExtensionCodec;
 use futures::StreamExt;
 use futures::stream::FuturesUnordered;
 use log::{error, info, warn};
-use sysinfo::{Disks, System};
+use sysinfo::{Disks, MemoryRefreshKind, System};
 use tempfile::TempDir;
 use tokio::fs::DirEntry;
 use tokio::signal;
@@ -80,6 +80,139 @@ use crate::shutdown::ShutdownNotifier;
 use crate::{ArrowFlightServerProvider, terminate};
 use crate::{execution_loop, executor_server};
 
+/// Default fraction of the detected memory limit handed to the pool when
+/// `--memory-pool-fraction` is not set. The `FairSpillPool` only bounds memory
+/// operators register with it, so the remaining budget absorbs untracked
+/// overhead (in-flight Arrow batches, shuffle writer buffers, fragmentation).
+pub(crate) const DEFAULT_MEMORY_POOL_FRACTION: f64 = 0.70;
+
+/// How the operator interpreted `--memory-pool-size`.
+#[derive(Debug, PartialEq)]
+enum MemoryBudget {
+    /// Flag unset: auto-size to `fraction` of detected host/cgroup memory.
+    Auto { fraction: f64 },
+    /// `--memory-pool-size 0`: run DataFusion's unbounded pool.
+    Unbounded,
+    /// `--memory-pool-size N`: bound the pool to exactly `N` bytes.
+    Bytes(u64),
+}
+
+/// Classify the raw CLI value into a [`MemoryBudget`]. `fraction` is only used
+/// for the auto path.
+fn memory_budget_from_cli(opt: Option<u64>, fraction: f64) -> MemoryBudget {
+    match opt {
+        None => MemoryBudget::Auto { fraction },
+        Some(0) => MemoryBudget::Unbounded,
+        Some(n) => MemoryBudget::Bytes(n),
+    }
+}
+
+/// Where an auto/explicit pool size came from (for logging).
+#[derive(Debug, PartialEq, Eq)]
+enum PoolSource {
+    /// Explicit `--memory-pool-size N`.
+    Configured,
+    /// Auto: fraction of the cgroup memory limit.
+    AutoCgroup,
+    /// Auto: fraction of host memory.
+    AutoHost,
+}
+
+/// Why the executor is running without a bounded pool (for logging).
+#[derive(Debug, PartialEq, Eq)]
+enum UnboundedReason {
+    /// User asked for it via `--memory-pool-size 0`.
+    Explicit,
+    /// Neither host nor cgroup memory could be detected.
+    Undetected,
+}
+
+/// The resolved pool decision: a concrete byte budget or unbounded.
+#[derive(Debug, PartialEq, Eq)]
+enum ResolvedPool {
+    Bounded { bytes: u64, source: PoolSource },
+    Unbounded(UnboundedReason),
+}
+
+/// Pure resolver: turn the budget plus detected limits into a decision.
+/// `host_total` and `cgroup_limit` are only consulted for 
[`MemoryBudget::Auto`].
+fn resolve_pool(
+    budget: MemoryBudget,
+    host_total: Option<u64>,
+    cgroup_limit: Option<u64>,
+) -> ResolvedPool {
+    match budget {
+        MemoryBudget::Unbounded => 
ResolvedPool::Unbounded(UnboundedReason::Explicit),
+        MemoryBudget::Bytes(n) => ResolvedPool::Bounded {
+            bytes: n,
+            source: PoolSource::Configured,
+        },
+        MemoryBudget::Auto { fraction } => {
+            let (base, source) = match (host_total, cgroup_limit) {
+                (Some(h), Some(c)) if c <= h => (c, PoolSource::AutoCgroup),
+                (Some(h), Some(_)) => (h, PoolSource::AutoHost),
+                (Some(h), None) => (h, PoolSource::AutoHost),
+                (None, Some(c)) => (c, PoolSource::AutoCgroup),
+                (None, None) => {
+                    return 
ResolvedPool::Unbounded(UnboundedReason::Undetected);
+                }
+            };
+            let bytes = (base as f64 * fraction) as u64;
+            ResolvedPool::Bounded { bytes, source }
+        }
+    }
+}
+
+/// Read the cgroup memory limit in bytes, or `None` if unlimited/absent.
+/// Tries cgroup v2 (`<root>/memory.max`) then v1
+/// (`<root>/memory/memory.limit_in_bytes`). A v1 "unlimited" sentinel (a value
+/// near `u64::MAX`) is returned as-is; callers clamp it with `min(host, _)`.
+fn read_cgroup_memory_limit(cgroup_root: &Path) -> Option<u64> {
+    // cgroup v2
+    if let Ok(s) = std::fs::read_to_string(cgroup_root.join("memory.max")) {
+        let s = s.trim();
+        if s == "max" {
+            return None;
+        }
+        if let Ok(v) = s.parse::<u64>() {
+            return Some(v);
+        }
+    }
+    // cgroup v1
+    if let Ok(s) =
+        
std::fs::read_to_string(cgroup_root.join("memory").join("memory.limit_in_bytes"))
+        && let Ok(v) = s.trim().parse::<u64>()
+    {
+        return Some(v);
+    }
+    None
+}
+
+/// Total host memory in bytes via `sysinfo`, or `None` if unreadable.
+fn read_host_total_memory() -> Option<u64> {
+    let mut system = System::new_all();
+    system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
+    let total = system.total_memory();
+    (total > 0).then_some(total)
+}
+
+/// Resolve the pool decision, reading host/cgroup limits only when 
auto-sizing.
+fn detect_pool(budget: MemoryBudget) -> ResolvedPool {
+    match budget {
+        MemoryBudget::Auto { fraction } => resolve_pool(
+            MemoryBudget::Auto { fraction },
+            read_host_total_memory(),
+            // Fixed cgroup root: assumes a cgroup-namespaced container (the
+            // default for modern k8s/Docker), where this path *is* the
+            // container's own limit. In a non-namespaced setup the real limit
+            // may live under a sub-slice instead, in which case this reads
+            // the host's root limit and we fall back to host memory below.
+            read_cgroup_memory_limit(Path::new("/sys/fs/cgroup")),
+        ),
+        other => resolve_pool(other, None, None),
+    }
+}
+
 /// Builds a per-task memory-pool policy: each task's runtime is rebuilt from 
the
 /// shared base env with a fresh [`FairSpillPool`] of size
 /// `total_bytes / vcores`. The base env's disk manager, cache manager,
@@ -173,6 +306,9 @@ pub struct ExecutorProcessConfig {
     /// `memory_pool_size / vcores`. When `None`, no pool is
     /// installed and DataFusion falls back to its unbounded default.
     pub memory_pool_size: Option<u64>,
+    /// Fraction (0.0, 1.0] of the detected host/cgroup memory limit used for 
the
+    /// auto memory pool when `memory_pool_size` is `None`.
+    pub memory_pool_fraction: f64,
     /// Maximum number of sessions whose shared base runtime env is retained on
     /// the executor (LRU). Sharing reuses object-store clients and the Parquet
     /// footer cache across a session's tasks and queries. `0` disables caching
@@ -245,6 +381,7 @@ impl Default for ExecutorProcessConfig {
             executor_heartbeat_interval_seconds: 60,
             metric_collection_policy: 
ExecutorMetricCollectionPolicy::default(),
             memory_pool_size: None,
+            memory_pool_fraction: DEFAULT_MEMORY_POOL_FRACTION,
             session_runtime_cache_capacity: 16,
             override_execution_engine: None,
             override_function_registry: None,
@@ -330,16 +467,43 @@ pub async fn start_executor_process(
             })
         });
 
-    let pool_policy: MemoryPoolPolicy = if let Some(total) = 
opt.memory_pool_size {
-        let policy = memory_pool_policy(total, vcores)?;
-        let per_vcore = total / vcores as u64;
-        info!(
-            "Memory pool: total {total} bytes split into {vcores} tasks 
({per_vcore} bytes each)"
-        );
-        policy
-    } else {
-        identity_pool_policy()
-    };
+    let fraction = opt.memory_pool_fraction;
+    let pool_policy: MemoryPoolPolicy =
+        match detect_pool(memory_budget_from_cli(opt.memory_pool_size, 
fraction)) {
+            ResolvedPool::Bounded { bytes, source } => {
+                let per_vcore = bytes / vcores as u64;
+                let source_desc = match source {
+                    PoolSource::Configured => {
+                        "configured via --memory-pool-size".to_string()
+                    }
+                    PoolSource::AutoCgroup => {
+                        format!("auto: {:.0}% of cgroup memory limit", 
fraction * 100.0)
+                    }
+                    PoolSource::AutoHost => {
+                        format!("auto: {:.0}% of host memory", fraction * 
100.0)
+                    }
+                };
+                info!(
+                    "Executor memory pool: {} ({source_desc}), \
+                     split across {vcores} vcores ({}/vcore)",
+                    bytesize::ByteSize::b(bytes),
+                    bytesize::ByteSize::b(per_vcore),
+                );
+                memory_pool_policy(bytes, vcores)?
+            }
+            ResolvedPool::Unbounded(reason) => {
+                match reason {
+                    UnboundedReason::Explicit => {
+                        info!("Executor memory pool: unbounded 
(--memory-pool-size 0)")
+                    }
+                    UnboundedReason::Undetected => warn!(
+                        "Executor memory pool: unbounded (could not detect 
host or \
+                         cgroup memory limit; set --memory-pool-size to enable 
spilling)"
+                    ),
+                }
+                identity_pool_policy()
+            }
+        };
 
     // Combined producer preserving the current per-task behavior: build a 
fresh
     // base env, then apply the pool policy. Used by 
`Executor::produce_runtime`
@@ -1184,4 +1348,160 @@ mod memory_pool_tests {
         let env = identity_pool_policy()(base.clone(), &SessionConfig::new(), 
1).unwrap();
         assert!(Arc::ptr_eq(&env, &base));
     }
+
+    #[test]
+    fn budget_from_cli_classifies_none_zero_and_positive() {
+        assert_eq!(
+            memory_budget_from_cli(None, 0.7),
+            MemoryBudget::Auto { fraction: 0.7 }
+        );
+        assert_eq!(
+            memory_budget_from_cli(Some(0), 0.7),
+            MemoryBudget::Unbounded
+        );
+        assert_eq!(
+            memory_budget_from_cli(Some(1024), 0.7),
+            MemoryBudget::Bytes(1024)
+        );
+    }
+
+    const GB: u64 = 1024 * 1024 * 1024;
+
+    #[test]
+    fn resolve_explicit_bytes_ignores_detection() {
+        assert_eq!(
+            resolve_pool(MemoryBudget::Bytes(4 * GB), Some(32 * GB), Some(8 * 
GB)),
+            ResolvedPool::Bounded {
+                bytes: 4 * GB,
+                source: PoolSource::Configured
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_unbounded_is_explicit() {
+        assert_eq!(
+            resolve_pool(MemoryBudget::Unbounded, Some(32 * GB), None),
+            ResolvedPool::Unbounded(UnboundedReason::Explicit)
+        );
+    }
+
+    #[test]
+    fn resolve_auto_prefers_cgroup_when_smaller() {
+        let expected = ((8 * GB) as f64 * 0.70) as u64;
+        assert_eq!(
+            resolve_pool(
+                MemoryBudget::Auto { fraction: 0.70 },
+                Some(32 * GB),
+                Some(8 * GB)
+            ),
+            ResolvedPool::Bounded {
+                bytes: expected,
+                source: PoolSource::AutoCgroup
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_auto_honors_custom_fraction() {
+        let expected = ((8 * GB) as f64 * 0.50) as u64;
+        assert_eq!(
+            resolve_pool(
+                MemoryBudget::Auto { fraction: 0.50 },
+                Some(32 * GB),
+                Some(8 * GB)
+            ),
+            ResolvedPool::Bounded {
+                bytes: expected,
+                source: PoolSource::AutoCgroup
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_auto_falls_back_to_host_when_cgroup_unlimited_sentinel() {
+        // cgroup v1 "unlimited" reports a value larger than host; min() picks 
host.
+        let expected = ((32 * GB) as f64 * 0.70) as u64;
+        assert_eq!(
+            resolve_pool(
+                MemoryBudget::Auto { fraction: 0.70 },
+                Some(32 * GB),
+                Some(u64::MAX)
+            ),
+            ResolvedPool::Bounded {
+                bytes: expected,
+                source: PoolSource::AutoHost
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_auto_host_only() {
+        let expected = ((32 * GB) as f64 * 0.70) as u64;
+        assert_eq!(
+            resolve_pool(MemoryBudget::Auto { fraction: 0.70 }, Some(32 * GB), 
None),
+            ResolvedPool::Bounded {
+                bytes: expected,
+                source: PoolSource::AutoHost
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_auto_cgroup_only() {
+        let expected = ((8 * GB) as f64 * 0.70) as u64;
+        assert_eq!(
+            resolve_pool(MemoryBudget::Auto { fraction: 0.70 }, None, Some(8 * 
GB)),
+            ResolvedPool::Bounded {
+                bytes: expected,
+                source: PoolSource::AutoCgroup
+            }
+        );
+    }
+
+    #[test]
+    fn resolve_auto_undetected_is_unbounded() {
+        assert_eq!(
+            resolve_pool(MemoryBudget::Auto { fraction: 0.70 }, None, None),
+            ResolvedPool::Unbounded(UnboundedReason::Undetected)
+        );
+    }
+
+    use std::fs;
+    use tempfile::tempdir;
+
+    #[test]
+    fn cgroup_v2_reads_numeric_limit() {
+        let dir = tempdir().unwrap();
+        fs::write(dir.path().join("memory.max"), "8589934592").unwrap();
+        assert_eq!(
+            read_cgroup_memory_limit(dir.path()),
+            Some(8 * 1024 * 1024 * 1024)
+        );
+    }
+
+    #[test]
+    fn cgroup_v2_max_means_unlimited() {
+        let dir = tempdir().unwrap();
+        fs::write(dir.path().join("memory.max"), "max\n").unwrap();
+        assert_eq!(read_cgroup_memory_limit(dir.path()), None);
+    }
+
+    #[test]
+    fn cgroup_v1_reads_numeric_limit() {
+        let dir = tempdir().unwrap();
+        let v1 = dir.path().join("memory");
+        fs::create_dir_all(&v1).unwrap();
+        fs::write(v1.join("memory.limit_in_bytes"), "8589934592\n").unwrap();
+        assert_eq!(
+            read_cgroup_memory_limit(dir.path()),
+            Some(8 * 1024 * 1024 * 1024)
+        );
+    }
+
+    #[test]
+    fn cgroup_absent_returns_none() {
+        let dir = tempdir().unwrap();
+        assert_eq!(read_cgroup_memory_limit(dir.path()), None);
+    }
 }
diff --git a/docs/source/user-guide/tuning-guide.md 
b/docs/source/user-guide/tuning-guide.md
index 4100e0b5a..6e0ba0c51 100644
--- a/docs/source/user-guide/tuning-guide.md
+++ b/docs/source/user-guide/tuning-guide.md
@@ -102,10 +102,18 @@ decreasing the vcore count may help.
 
 ## Configuring Executor Memory Pool
 
-By default the executor uses DataFusion's unbounded memory pool, so spillable
-operators (sort, hash join, hash aggregate) grow until the host runs out of
-memory. To bound executor memory and let those operators spill to disk under
-pressure, pass `--memory-pool-size` when starting the executor:
+By default the executor auto-sizes a bounded memory pool from the detected
+host or cgroup memory limit, so spillable operators (sort, hash join, hash
+aggregate) spill to disk under pressure instead of growing until the host
+runs out of memory. The pool is sized to `--memory-pool-fraction` (default
+`0.70`, i.e. 70%) of the detected limit, so headroom is left for untracked
+overhead such as in-flight Arrow batches, shuffle writer buffers, and
+fragmentation. When running under a cgroup-limited container (the common
+Kubernetes/Docker case), the cgroup limit is used in preference to host
+memory when it is the tighter of the two.
+
+To use a specific budget instead of auto-detection, pass `--memory-pool-size`
+when starting the executor:
 
 ```sh
 ballista-executor --memory-pool-size 8GB --vcores 8
@@ -113,20 +121,27 @@ ballista-executor --memory-pool-size 8GB --vcores 8
 
 The argument accepts human-readable sizes (`8GB`, `512MiB`) or a plain byte
 count. SI suffixes (`KB`/`MB`/`GB`) are powers of 10; IEC suffixes
-(`KiB`/`MiB`/`GiB`) are powers of 2.
-
-The total budget is divided equally across the executor's vcores: each task
-receives its own `FairSpillPool` of size `memory_pool_size / vcores`.
-With `--memory-pool-size 8GB --vcores 8`, every task sees a 1 GB
-pool, fully isolated from other tasks. Idle slots do not lend their share to
-busy ones, which keeps task memory predictable at the cost of some unused
-capacity when the executor is under-utilized.
+(`KiB`/`MiB`/`GiB`) are powers of 2. Pass `--memory-pool-size 0` to opt out of
+the pool entirely and run DataFusion's unbounded pool, matching the executor's
+pre-auto-sizing behavior.
+
+Whether the budget comes from auto-detection or `--memory-pool-size`, it is
+divided equally across the executor's vcores: each task receives its own
+`FairSpillPool` of size `memory_pool_size / vcores`. With
+`--memory-pool-size 8GB --vcores 8`, every task sees a 1 GB pool, fully
+isolated from other tasks. Idle slots do not lend their share to busy ones,
+which keeps task memory predictable at the cost of some unused capacity when
+the executor is under-utilized.
 
 The executor refuses to start if the per-task share would round to zero (i.e.
 `memory_pool_size < vcores`).
 
-When `--memory-pool-size` is not set, the executor behaves as before with no
-memory pool installed.
+Use `--memory-pool-fraction` to tune how much of the detected memory limit
+the auto-sized pool uses; it accepts a value in `(0.0, 1.0]` and defaults to
+`0.70`. It is ignored when `--memory-pool-size` is set.
+
+Because operators spill to disk rather than fail, make sure the executor's
+`--work-dir` has enough scratch space to absorb spills under memory pressure.
 
 ## Join Strategy
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to