Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-worker/src/capacity.rs
Line
Count
Source
1
// Copyright 2026 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
//! What this worker can see of its own CPU and memory, and what it should
16
//! advertise from that.
17
//!
18
//! Every deployment so far typed the advertisement by hand next to the pod's
19
//! limits, and the two drifted: chinchilla advertised 16 cores and 60 GiB
20
//! inside 15-core, 56 GiB limits. The limit is the number the kernel
21
//! enforces, so the advertisement is derived from it here.
22
23
use core::hash::BuildHasher;
24
use std::collections::HashMap;
25
use std::path::{Path, PathBuf};
26
27
use nativelink_config::cas_server::{
28
    CapacityConfig, CpuUnit, MemoryEnforcement, ResourceEnforcementConfig, WorkerProperty,
29
};
30
use nativelink_error::{Code, Error, make_err};
31
use tracing::{info, warn};
32
33
/// CPU and memory as the cgroup (or, failing a limit, the host) reports them.
34
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
pub struct ObservedCapacity {
36
    pub cpu_millicores: u64,
37
    pub memory_kb: u64,
38
}
39
40
/// `cpu.max` is `"<quota> <period>"` in microseconds, or `"max <period>"` when
41
/// unlimited; unlimited means the host's cores.
42
4
pub fn parse_cpu_max(cpu_max: &str, host_cpus: u64) -> Option<u64> {
43
4
    let mut fields = cpu_max.split_whitespace();
44
4
    let quota = fields.next()
?0
;
45
4
    if quota == "max" {
46
1
        return Some(host_cpus.saturating_mul(1000));
47
3
    }
48
3
    let 
quota2
:
u642
= quota.parse().ok()
?1
;
49
2
    let period: u64 = fields.next()
?0
.parse().ok()
?0
;
50
2
    if period == 0 {
51
1
        return None;
52
1
    }
53
1
    Some(quota.saturating_mul(1000) / period)
54
4
}
55
56
/// `memory.max` is bytes, or `"max"` when unlimited; unlimited means the
57
/// host's memory.
58
3
pub fn parse_memory_max(memory_max: &str, host_memory_kb: u64) -> Option<u64> {
59
3
    let value = memory_max.trim();
60
3
    if value == "max" {
61
1
        return Some(host_memory_kb);
62
2
    }
63
2
    value.parse::<u64>().ok().map(|bytes| 
bytes1
/ 1024)
64
3
}
65
66
/// The number after `key` on the first line that starts with it, as
67
/// `/proc/meminfo` (`MemTotal:  65536000 kB`) and a cgroup's `memory.stat`
68
/// (`inactive_file 18672467968`) both lay them out. A key without its own
69
/// colon must be followed by whitespace, so `inactive_file` does not match
70
/// `inactive_file_extra`.
71
69.0k
fn keyed_value(text: &str, key: &str) -> Option<u64> {
72
206k
    
text.lines()69.0k
.
find_map69.0k
(|line| {
73
206k
        let 
rest69.0k
= line.strip_prefix(key)
?137k
;
74
69.0k
        if !key.ends_with(':') && 
!rest.starts_with(char::is_whitespace)16
{
75
2
            return None;
76
69.0k
        }
77
69.0k
        rest.split_whitespace().next()
?0
.parse().ok()
78
206k
    })
79
69.0k
}
80
81
/// `MemTotal` from `/proc/meminfo`, in KiB.
82
85
pub fn parse_meminfo_total_kb(meminfo: &str) -> Option<u64> {
83
85
    keyed_value(meminfo, "MemTotal:")
84
85
}
85
86
/// The headroom the advertisement is divided by: the block's own figure
87
/// when set, else the enforcement headroom while memory enforcement is on,
88
/// so the two numbers cannot drift apart, else nothing.
89
4
pub fn memory_headroom_percent(
90
4
    config: &CapacityConfig,
91
4
    enforcement: Option<&ResourceEnforcementConfig>,
92
4
) -> u64 {
93
4
    config.memory_headroom_percent.unwrap_or_else(|| 
{3
94
3
        enforcement
95
3
            .filter(|enforcement| 
enforcement.memory2
==
MemoryEnforcement::Soft2
)
96
3
            .map_or(0, |enforcement| enforcement.memory_headroom_percent)
97
3
    })
98
4
}
99
100
/// The observed capacity less the worker's own share, memory divided by
101
/// `memory_headroom_percent`. Returns `(cpu, memory_kb)` with the CPU on
102
/// the scale `cpu_unit` names: whole cores rounded down, so a 14-core pod
103
/// keeping one core back advertises `13`, not `13000`, to a scheduler
104
/// whose actions ask for `cpu_count=1`.
105
9
pub const fn advertised(
106
9
    observed: ObservedCapacity,
107
9
    config: &CapacityConfig,
108
9
    memory_headroom_percent: u64,
109
9
) -> (u64, u64) {
110
9
    let cpu_millicores = observed
111
9
        .cpu_millicores
112
9
        .saturating_sub(config.overhead_cpu_millicores);
113
9
    let cpu = match config.cpu_unit {
114
7
        CpuUnit::Cores => cpu_millicores / 1000,
115
2
        CpuUnit::Millicores => cpu_millicores,
116
    };
117
9
    let memory = observed
118
9
        .memory_kb
119
9
        .saturating_sub(config.overhead_memory_kb)
120
9
        .saturating_mul(100)
121
9
        / (100 + memory_headroom_percent);
122
9
    (cpu, memory)
123
9
}
124
125
/// The largest memory reservation the scheduler can match to this worker:
126
/// what it advertises under `property`, from the cgroup when the capacity
127
/// block sets that property and the cgroup was read, else from the static
128
/// platform properties. `None` when the worker advertises no such figure.
129
5
pub fn advertised_memory_kb<S: BuildHasher>(
130
5
    capacity: Option<&CapacityConfig>,
131
5
    memory_headroom_percent: u64,
132
5
    observed: Option<ObservedCapacity>,
133
5
    properties: &HashMap<String, WorkerProperty, S>,
134
5
    property: &str,
135
5
) -> Option<u64> {
136
5
    if let (Some(
config3
), Some(
observed3
)) = (capacity, observed)
137
3
        && config.memory_property_name == property
138
    {
139
2
        return Some(advertised(observed, config, memory_headroom_percent).1);
140
3
    }
141
3
    match properties.get(property)
?1
{
142
2
        WorkerProperty::Values(values) => values.iter().filter_map(|v| v.parse().ok()).max(),
143
0
        WorkerProperty::QueryCmd(_) => None,
144
    }
145
5
}
146
147
/// What to do when the cgroup cannot be read: the configured properties
148
/// stand if they carry both numbers, since an operator who kept them has a
149
/// worker that still takes work; a worker with neither would register and
150
/// then satisfy no action that asks for CPU or memory, idling for good on
151
/// one warning line, so that is refused.
152
3
pub fn without_cgroup<S: BuildHasher>(
153
3
    config: &CapacityConfig,
154
3
    properties: &HashMap<String, WorkerProperty, S>,
155
3
) -> Result<(), Error> {
156
3
    let missing: Vec<&str> = [&config.cpu_property_name, &config.memory_property_name]
157
3
        .into_iter()
158
6
        .
filter3
(|name| !properties.contains_key(name.as_str()))
159
3
        .map(String::as_str)
160
3
        .collect();
161
3
    if missing.is_empty() {
162
1
        warn!(
163
            "capacity is configured but no cgroup v2 limit applies to the worker (see the line above for the cgroup looked at); advertising the configured platform_properties instead"
164
        );
165
1
        return Ok(());
166
2
    }
167
2
    Err(make_err!(
168
2
        Code::FailedPrecondition,
169
2
        "capacity is configured but no cgroup v2 limit applies to the worker (not Linux, cgroup v1, no permission, or no limit at any level; see the line above), and platform_properties carries no {}: set them, or drop the capacity block",
170
2
        missing.join(" or ")
171
2
    ))
172
3
}
173
174
/// The cgroup v2 path of this process from `/proc/self/cgroup`: the `0::`
175
/// line, which is `/` inside a container with its own cgroup namespace and
176
/// the full `/kubepods.slice/.../cri-containerd-<id>.scope` path in one that
177
/// shares the host's, as a privileged container does.
178
71.0k
pub fn parse_self_cgroup(self_cgroup: &str) -> Option<&str> {
179
71.0k
    self_cgroup
180
71.0k
        .lines()
181
71.0k
        .find_map(|line| line.strip_prefix("0::"))
182
71.0k
        .map(str::trim)
183
71.0k
        .filter(|path| 
path71.0k
.
starts_with71.0k
('/'))
184
71.0k
}
185
186
/// Where this process's own `cpu.max`, `memory.max` and `memory.current`
187
/// live: the mount root joined with the path from `/proc/self/cgroup` when
188
/// that directory exists under the mount, the mount root otherwise. A
189
/// container with a private cgroup namespace sees itself at the root, so its
190
/// path is `/` and both agree. A privileged container sees the host's tree,
191
/// where the root's files are the host's (or absent, as on a systemd host),
192
/// and its own numbers sit further down.
193
71.0k
pub fn resolve_cgroup_dir(
194
71.0k
    mount_root: &Path,
195
71.0k
    self_cgroup: Option<&str>,
196
71.0k
    is_dir: impl Fn(&Path) -> bool,
197
71.0k
) -> PathBuf {
198
71.0k
    if let Some(
path71.0k
) = self_cgroup.and_then(parse_self_cgroup) {
199
71.0k
        let own = mount_root.join(path.trim_start_matches('/'));
200
71.0k
        if is_dir(&own) {
201
2
            return own;
202
71.0k
        }
203
1
    }
204
71.0k
    mount_root.to_path_buf()
205
71.0k
}
206
207
/// This process's cgroup directory on the live system.
208
71.0k
fn cgroup_dir() -> PathBuf {
209
71.0k
    let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").ok();
210
71.0k
    resolve_cgroup_dir(Path::new("/sys/fs/cgroup"), self_cgroup.as_deref(), |dir| {
211
71.0k
        dir.join("memory.max").is_file()
212
71.0k
    })
213
71.0k
}
214
215
/// The `avg10` of the `full` line of a cgroup v2 `memory.pressure` file:
216
/// the share of the last ten seconds (0 to 100) during which every task in the
217
/// cgroup was stalled on memory. `None` when the text has no such line.
218
4
pub fn parse_pressure_full_avg10(text: &str) -> Option<f64> {
219
4
    text.lines().find_map(|line| {
220
4
        let 
rest2
= line.strip_prefix("full")
?2
;
221
2
        rest.split_whitespace()
222
2
            .find_map(|field| field.strip_prefix("avg10="))
223
2
            .and_then(|value| value.parse().ok())
224
4
    })
225
4
}
226
227
/// The `memory.pressure` file that describes this worker: the one in the
228
/// nearest limited cgroup (the limit the kernel enforces, see
229
/// [`find_limit`]), else the one in the worker's own cgroup directory.
230
/// `None` where neither exists: cgroup v1, a kernel without PSI, or a
231
/// cgroup root, which has no `memory.pressure`.
232
#[cfg(target_os = "linux")]
233
3
pub fn memory_pressure_file() -> Option<PathBuf> {
234
3
    let mount_root = Path::new("/sys/fs/cgroup");
235
3
    let own = cgroup_dir();
236
3
    let limited = find_limit(&own, mount_root, "memory.max", read_file).map(|(dir, _)| dir);
237
3
    [limited, Some(own)]
238
3
        .into_iter()
239
3
        .flatten()
240
3
        .map(|dir| dir.join("memory.pressure"))
241
3
        .find(|path| path.is_file())
242
3
}
243
244
/// Whether a `cpu.max` or `memory.max` value is "no limit": `max`, or for
245
/// `cpu.max` `max <period>`.
246
8
fn is_unlimited(value: &str) -> bool {
247
8
    value.split_whitespace().next() == Some("max")
248
8
}
249
250
/// The first limit for `file` from `dir` up to and including `mount_root`,
251
/// with the directory it was found in. The process's own cgroup is often
252
/// an unlimited child of a limited parent (a systemd scope in a pod, a
253
/// split cgroup, a privileged container under `kubepods.slice`), and the
254
/// parent's limit is the one the kernel enforces on it. `None` when every
255
/// level says `max` or nothing is readable: no limit applies.
256
69.0k
pub fn find_limit(
257
69.0k
    dir: &Path,
258
69.0k
    mount_root: &Path,
259
69.0k
    file: &str,
260
69.0k
    read: impl Fn(&Path) -> Option<String>,
261
69.0k
) -> Option<(PathBuf, String)> {
262
69.0k
    let mut at = dir;
263
    loop {
264
69.1k
        if let Some(
value8
) = read(&at.join(file))
265
8
            && !is_unlimited(value.trim())
266
        {
267
3
            return Some((at.to_path_buf(), value));
268
69.0k
        }
269
69.0k
        if at == mount_root || 
!9
at9
.starts_with(mount_root) {
270
69.0k
            return None;
271
9
        }
272
9
        at = at.parent()
?0
;
273
    }
274
69.0k
}
275
276
142k
fn read_file(path: &Path) -> Option<String> {
277
142k
    std::fs::read_to_string(path).ok()
278
142k
}
279
280
/// Reads the limits that apply to the worker: for CPU and for memory, the
281
/// nearest limit from its own cgroup up to the mount root. `None` where no
282
/// limit applies at any level (not Linux, cgroup v1, no permission, or a
283
/// host where nothing is limited), so the configured properties stand.
284
82
pub fn observe_cgroup() -> Option<ObservedCapacity> {
285
82
    let host_cpus = std::thread::available_parallelism().map_or(1, |n| n.get() as u64);
286
82
    let host_memory_kb = std::fs::read_to_string("/proc/meminfo")
287
82
        .ok()
288
82
        .and_then(|meminfo| parse_meminfo_total_kb(&meminfo))
289
82
        .unwrap_or(0);
290
82
    let mount_root = Path::new("/sys/fs/cgroup");
291
82
    let dir = cgroup_dir();
292
82
    let cpu = find_limit(&dir, mount_root, "cpu.max", read_file);
293
82
    let memory = find_limit(&dir, mount_root, "memory.max", read_file);
294
82
    if cpu.is_none() && memory.is_none() {
295
82
        warn!(
296
82
            cgroup = %dir.display(),
297
            "no cgroup v2 cpu.max or memory.max limit from this cgroup up to the mount root; keeping the configured platform_properties"
298
        );
299
82
        return None;
300
0
    }
301
0
    let cpu_max = cpu.map_or_else(|| "max".to_string(), |(_, value)| value);
302
0
    let memory_max = memory.map_or_else(|| "max".to_string(), |(_, value)| value);
303
    Some(ObservedCapacity {
304
0
        cpu_millicores: parse_cpu_max(&cpu_max, host_cpus)?,
305
0
        memory_kb: parse_memory_max(&memory_max, host_memory_kb)?,
306
    })
307
82
}
308
309
/// Sets the CPU and memory properties to what the cgroup allows. When
310
/// nothing can be read, the configured properties stand if they carry both,
311
/// and the worker fails to start if they do not. Returns what was
312
/// advertised.
313
0
pub fn apply<S: BuildHasher>(
314
0
    config: &CapacityConfig,
315
0
    memory_headroom_percent: u64,
316
0
    properties: &mut HashMap<String, WorkerProperty, S>,
317
0
) -> Result<Option<(u64, u64)>, Error> {
318
0
    let Some(observed) = observe_cgroup() else {
319
0
        without_cgroup(config, properties)?;
320
0
        return Ok(None);
321
    };
322
0
    let (cpu, memory_kb) = advertised(observed, config, memory_headroom_percent);
323
0
    properties.insert(
324
0
        config.cpu_property_name.clone(),
325
0
        WorkerProperty::Values(vec![cpu.to_string()]),
326
    );
327
0
    properties.insert(
328
0
        config.memory_property_name.clone(),
329
0
        WorkerProperty::Values(vec![memory_kb.to_string()]),
330
    );
331
0
    info!(
332
        observed_cpu_millicores = observed.cpu_millicores,
333
        observed_memory_kb = observed.memory_kb,
334
        cpu,
335
        cpu_unit = ?config.cpu_unit,
336
        memory_kb,
337
        memory_headroom_percent,
338
        "Advertising capacity from the cgroup"
339
    );
340
0
    Ok(Some((cpu, memory_kb)))
341
0
}
342
343
18
pub fn parse_memory_current_kb(memory_current: &str) -> Option<u64> {
344
18
    memory_current
345
18
        .trim()
346
18
        .parse::<u64>()
347
18
        .ok()
348
18
        .map(|bytes| 
bytes16
/ 1024)
349
18
}
350
351
/// `MemAvailable` from `/proc/meminfo`, in KiB.
352
68.9k
pub fn parse_meminfo_available_kb(meminfo: &str) -> Option<u64> {
353
68.9k
    keyed_value(meminfo, "MemAvailable:")
354
68.9k
}
355
356
/// The page cache in a cgroup's `memory.stat` the kernel can drop at once,
357
/// in KiB: `inactive_file`, the cache it reclaims first and what kubelet
358
/// leaves out of the working set it evicts on, less `file_dirty` and
359
/// `file_writeback`, which cannot be dropped until they reach the disk
360
/// (kubelet does not take those off, so this reads up to that much lower).
361
/// Dirty pages may sit on the active list instead; taking all of them off
362
/// the inactive one errs towards less free, never more. `None` without an
363
/// `inactive_file` line.
364
11
pub fn parse_memory_stat_reclaimable_kb(memory_stat: &str) -> Option<u64> {
365
11
    let 
inactive_file9
= keyed_value(memory_stat, "inactive_file")
?2
;
366
9
    let dirty = keyed_value(memory_stat, "file_dirty").unwrap_or(0);
367
9
    let writeback = keyed_value(memory_stat, "file_writeback").unwrap_or(0);
368
9
    Some(
369
9
        inactive_file
370
9
            .saturating_sub(dirty)
371
9
            .saturating_sub(writeback)
372
9
            / 1024,
373
9
    )
374
11
}
375
376
/// Memory the worker could still give an action: the cgroup limit less its
377
/// working set (current usage less the page cache the kernel can drop)
378
/// when there is a limit, the host's `MemAvailable` when there is not,
379
/// nothing when neither can be read. `memory.current` charges every page
380
/// of file cache the worker's I/O has touched, and the kernel only reclaims
381
/// it near the limit, so a busy worker's usage sits just under its limit
382
/// with little of it in use.
383
11
pub const fn free_memory_kb_from(
384
11
    limit_kb: Option<u64>,
385
11
    current_kb: Option<u64>,
386
11
    reclaimable_kb: Option<u64>,
387
11
    available_kb: Option<u64>,
388
11
) -> Option<u64> {
389
11
    match (limit_kb, current_kb) {
390
9
        (Some(limit), Some(current)) => {
391
9
            let reclaimable = match reclaimable_kb {
392
6
                Some(kb) => kb,
393
3
                None => 0,
394
            };
395
9
            Some(limit.saturating_sub(current.saturating_sub(reclaimable)))
396
        }
397
2
        _ => available_kb,
398
    }
399
11
}
400
401
/// Free memory measured against `limited`, the nearest limited cgroup and
402
/// its `memory.max` as `find_limit` returns them, with `read` for the
403
/// files; the host's `MemAvailable` when there is no limit.
404
68.9k
pub fn free_memory_kb_with(
405
68.9k
    limited: Option<&(PathBuf, String)>,
406
68.9k
    read: impl Fn(&Path) -> Option<String>,
407
68.9k
) -> Option<u64> {
408
68.9k
    let Some((
dir4
,
max4
)) = limited else {
409
68.9k
        return read(Path::new("/proc/meminfo"))
410
68.9k
            .and_then(|meminfo| parse_meminfo_available_kb(&meminfo));
411
    };
412
4
    let read_current =
413
8
        || read(&dir.join("memory.current")).and_then(|current| 
parse_memory_current_kb6
(
&current6
));
414
    // The usage is read on both sides of the cache, and the larger taken.
415
    // That mitigates the common races, cache that grows during the read or
416
    // is reclaimed during it, but the three reads are not one snapshot:
417
    // cache that grows at the `memory.stat` read and is reclaimed before
418
    // the second usage read is missed by both usage reads yet subtracted,
419
    // so a transient cache peak can report more free than there is. The
420
    // cache figure also comes from per-CPU counters the kernel flushes in
421
    // batches, so it can run ahead of what is there by a few MiB per CPU.
422
4
    let current_before = read_current();
423
4
    let reclaimable_kb =
424
4
        read(&dir.join("memory.stat")).and_then(|stat| parse_memory_stat_reclaimable_kb(&stat));
425
4
    let current_kb = current_before.max(read_current());
426
4
    match (parse_memory_current_kb(max.trim()), current_kb) {
427
3
        (Some(limit_kb), Some(current_kb)) => {
428
3
            free_memory_kb_from(Some(limit_kb), Some(current_kb), reclaimable_kb, None)
429
        }
430
1
        _ => read(Path::new("/proc/meminfo"))
431
1
            .and_then(|meminfo| parse_meminfo_available_kb(&meminfo)),
432
    }
433
68.9k
}
434
435
/// Whether `dir`, the worker's own cgroup, carries a memory limit of its
436
/// own with readable usage: then nothing outside the worker gives memory
437
/// back, so what it reads free while idle is the most it will read. A limit
438
/// only on an ancestor (a shared `kubepods.slice`, say) is shared with
439
/// other workloads, which can release memory, and without readable usage
440
/// free memory falls back to the host's `MemAvailable`; neither counts.
441
2.03k
pub fn own_memory_limit_in(dir: &Path, read: impl Fn(&Path) -> Option<String>) -> bool {
442
2.03k
    let limited = read(&dir.join("memory.max"))
443
2.03k
        .is_some_and(|max| 
parse_memory_current_kb3
(max.trim()).
is_some3
());
444
2.03k
    let usage = read(&dir.join("memory.current"))
445
2.03k
        .is_some_and(|current| 
parse_memory_current_kb(&current)3
.
is_some3
());
446
2.03k
    limited && 
usage2
447
2.03k
}
448
449
/// `own_memory_limit_in` for this process's cgroup.
450
#[cfg(target_os = "linux")]
451
2.03k
pub fn memory_is_limited() -> bool {
452
2.03k
    own_memory_limit_in(&cgroup_dir(), read_file)
453
2.03k
}
454
455
#[cfg(not(target_os = "linux"))]
456
pub const fn memory_is_limited() -> bool {
457
    false
458
}
459
460
/// What the worker reports on each keepalive. Linux only; elsewhere the
461
/// worker reports nothing and is never vetoed.
462
#[cfg(target_os = "linux")]
463
68.9k
pub fn free_memory_kb() -> Option<u64> {
464
    // The limit and the usage counted against it come from the same level:
465
    // the nearest limited ancestor, which charges everything below it.
466
68.9k
    let limited = find_limit(
467
68.9k
        &cgroup_dir(),
468
68.9k
        Path::new("/sys/fs/cgroup"),
469
68.9k
        "memory.max",
470
        read_file,
471
    );
472
68.9k
    free_memory_kb_with(limited.as_ref(), read_file)
473
68.9k
}
474
475
#[cfg(not(target_os = "linux"))]
476
pub const fn free_memory_kb() -> Option<u64> {
477
    None
478
}