Skip to content

Commit ec59597

Browse files
committed
Blend file count with byte size in subtree locality scoring
Add subtree_files tracking alongside byte size for more accurate cache coverage estimation. Introduce PER_FILE_WEIGHT constant for blended scoring in coverage winner selection. Demote per-dispatch scheduling logs to debug! while keeping quarantine recovery at info!.
1 parent 0a1b83f commit ec59597

File tree

1 file changed

+60
-31
lines changed

1 file changed

+60
-31
lines changed

nativelink-scheduler/src/api_worker_scheduler.rs

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -511,62 +511,75 @@ impl ApiWorkerSchedulerImpl {
511511
};
512512

513513
// ── Tier 1.5: Partial subtree coverage scoring ──
514-
// When no worker has the exact root cached, score workers by the total
515-
// file bytes under their cached subtrees. A worker caching a subtree with
516-
// 10GB of files scores higher than one caching a subtree with 100 bytes.
517-
// We sum the subtree_bytes for each matching directory, taking only the
518-
// top-level match (avoid double-counting nested matches).
514+
// When no worker has the exact root cached, score workers by a blended
515+
// metric of cached bytes and cached file count. Each cached file is
516+
// worth PER_FILE_WEIGHT bytes in the score because hardlink/clonefile
517+
// operations have a fixed per-file I/O cost (~0.1ms each, equivalent
518+
// to ~100KB of network transfer at 10Gbps).
519+
const PER_FILE_WEIGHT: u64 = 100 * 1024; // 100KB per file
519520
let subtree_coverage_winner: Option<WorkerId> = if dir_cache_winner.is_some() {
520521
None // exact match found, skip coverage scoring
521522
} else if let Some(tree) = resolved_tree {
522523
let total_bytes: u64 = tree.subtree_bytes.get(&input_root_digest).copied().unwrap_or(0);
523-
if tree.dir_digests.len() <= 1 || total_bytes == 0 {
524+
let total_files: u64 = tree.subtree_files.get(&input_root_digest).copied().unwrap_or(0);
525+
let total_score = total_bytes + total_files * PER_FILE_WEIGHT;
526+
if tree.dir_digests.len() <= 1 || total_score == 0 {
524527
None // only root (or empty), no subtrees to match
525528
} else {
526-
let mut best: Option<(WorkerId, u64, u32)> = None; // (id, cached_bytes, cpu_load)
529+
// (id, cached_score, cached_bytes, cached_files, cpu_load)
530+
let mut best: Option<(WorkerId, u64, u64, u64, u32)> = None;
527531
for wid in &candidates {
528532
if let Some(w) = self.workers.0.peek(wid) {
529533
if !worker_is_viable(wid) {
530534
continue;
531535
}
532-
// Sum the subtree_bytes for each of the action's directory
536+
// Sum bytes and files for each of the action's directory
533537
// digests that this worker has cached.
534-
let cached_bytes: u64 = tree.dir_digests.iter()
538+
let (cached_bytes, cached_files): (u64, u64) = tree.dir_digests.iter()
535539
.filter(|d| w.cached_subtree_digests.contains(d))
536-
.map(|d| tree.subtree_bytes.get(d).copied().unwrap_or(0))
537-
.sum();
538-
if cached_bytes == 0 {
540+
.fold((0u64, 0u64), |(ab, af), d| {
541+
(
542+
ab + tree.subtree_bytes.get(d).copied().unwrap_or(0),
543+
af + tree.subtree_files.get(d).copied().unwrap_or(0),
544+
)
545+
});
546+
let cached_score = cached_bytes + cached_files * PER_FILE_WEIGHT;
547+
if cached_score == 0 {
539548
continue;
540549
}
541550
let load = w.cpu_load_pct;
542-
let dominated = best.as_ref().is_some_and(|(_, best_bytes, best_load)| {
543-
if cached_bytes != *best_bytes {
544-
return cached_bytes < *best_bytes;
551+
let dominated = best.as_ref().is_some_and(|(_, best_score, _, _, best_load)| {
552+
if cached_score != *best_score {
553+
return cached_score < *best_score;
545554
}
546-
// Same coverage — prefer lower CPU load.
555+
// Same score — prefer lower CPU load.
547556
let effective_best = if *best_load == 0 { u32::MAX } else { *best_load };
548557
let effective_this = if load == 0 { u32::MAX } else { load };
549558
effective_this >= effective_best
550559
});
551560
if !dominated {
552-
best = Some((wid.clone(), cached_bytes, load));
561+
best = Some((wid.clone(), cached_score, cached_bytes, cached_files, load));
553562
}
554563
}
555564
}
556-
if let Some((ref wid, cached_bytes, load)) = best {
557-
let pct = if total_bytes > 0 { cached_bytes * 100 / total_bytes } else { 0 };
565+
if let Some((ref wid, cached_score, cached_bytes, cached_files, load)) = best {
566+
let pct = if total_score > 0 { cached_score * 100 / total_score } else { 0 };
558567
debug!(
559568
?wid,
560569
cached_bytes,
570+
cached_files,
561571
total_bytes,
572+
total_files,
573+
cached_score,
574+
total_score,
562575
cpu_load_pct = load,
563576
coverage_pct = pct,
564577
%input_root_digest,
565-
"Subtree coverage winner -- worker has {}% of input tree bytes cached in subtrees",
578+
"Subtree coverage winner -- worker has {}% of input tree (bytes+files) cached",
566579
pct,
567580
);
568581
}
569-
best.map(|(wid, _, _)| wid)
582+
best.map(|(wid, _, _, _, _)| wid)
570583
}
571584
} else {
572585
None
@@ -1284,6 +1297,11 @@ struct ResolvedTree {
12841297
/// Used to weight subtree coverage scoring — a subtree with 10GB
12851298
/// of files is worth more than one with 100 bytes.
12861299
subtree_bytes: HashMap<DigestInfo, u64>,
1300+
/// Total file count under each directory subtree (recursive).
1301+
/// Blended with subtree_bytes for coverage scoring: many small files
1302+
/// have higher per-file I/O cost (hardlinks, clonefile) than fewer
1303+
/// large files at the same total byte count.
1304+
subtree_files: HashMap<DigestInfo, u64>,
12871305
}
12881306

12891307
/// Resolves a directory tree from the CAS store by recursively reading
@@ -1304,8 +1322,9 @@ async fn resolve_tree_from_cas(
13041322
let mut seen_dirs: HashSet<DigestInfo> = HashSet::new();
13051323
seen_dirs.insert(root_digest);
13061324

1307-
// Track tree structure for bottom-up subtree size computation.
1325+
// Track tree structure for bottom-up subtree size/file-count computation.
13081326
let mut dir_direct_bytes: HashMap<DigestInfo, u64> = HashMap::new();
1327+
let mut dir_direct_files: HashMap<DigestInfo, u64> = HashMap::new();
13091328
let mut dir_children: HashMap<DigestInfo, Vec<DigestInfo>> = HashMap::new();
13101329
// BFS order — used for bottom-up traversal (reverse of BFS = leaves first).
13111330
let mut bfs_order: Vec<DigestInfo> = vec![root_digest];
@@ -1337,20 +1356,23 @@ async fn resolve_tree_from_cas(
13371356
for result in results {
13381357
let (parent_digest, directory) = result?;
13391358

1340-
// Sum direct file bytes for this directory.
1359+
// Sum direct file bytes and count for this directory.
13411360
let mut direct_bytes: u64 = 0;
1361+
let mut direct_files: u64 = 0;
13421362
for file_node in &directory.files {
13431363
if let Some(ref digest) = file_node.digest {
13441364
if let Ok(digest_info) = DigestInfo::try_from(digest) {
13451365
let size = digest_info.size_bytes();
13461366
direct_bytes += size;
1367+
direct_files += 1;
13471368
if seen_files.insert(digest_info) {
13481369
file_digests.push((digest_info, size));
13491370
}
13501371
}
13511372
}
13521373
}
13531374
dir_direct_bytes.insert(parent_digest, direct_bytes);
1375+
dir_direct_files.insert(parent_digest, direct_files);
13541376

13551377
// Queue subdirectories for visiting (dedup via seen_dirs).
13561378
let mut children = Vec::new();
@@ -1369,27 +1391,34 @@ async fn resolve_tree_from_cas(
13691391
}
13701392
}
13711393

1372-
// Bottom-up pass: compute total file bytes under each subtree.
1394+
// Bottom-up pass: compute total file bytes and file count under each subtree.
13731395
// Reverse BFS order gives us leaves-first, so children are always
13741396
// computed before parents.
13751397
let mut subtree_bytes: HashMap<DigestInfo, u64> = HashMap::new();
1398+
let mut subtree_files: HashMap<DigestInfo, u64> = HashMap::new();
13761399
for &dir_digest in bfs_order.iter().rev() {
1377-
let direct = dir_direct_bytes.get(&dir_digest).copied().unwrap_or(0);
1378-
let children_total: u64 = dir_children
1400+
let direct_b = dir_direct_bytes.get(&dir_digest).copied().unwrap_or(0);
1401+
let direct_f = dir_direct_files.get(&dir_digest).copied().unwrap_or(0);
1402+
let (children_bytes, children_files): (u64, u64) = dir_children
13791403
.get(&dir_digest)
13801404
.map(|children| {
1381-
children.iter()
1382-
.map(|c| subtree_bytes.get(c).copied().unwrap_or(0))
1383-
.sum()
1405+
children.iter().fold((0u64, 0u64), |(ab, af), c| {
1406+
(
1407+
ab + subtree_bytes.get(c).copied().unwrap_or(0),
1408+
af + subtree_files.get(c).copied().unwrap_or(0),
1409+
)
1410+
})
13841411
})
1385-
.unwrap_or(0);
1386-
subtree_bytes.insert(dir_digest, direct + children_total);
1412+
.unwrap_or((0, 0));
1413+
subtree_bytes.insert(dir_digest, direct_b + children_bytes);
1414+
subtree_files.insert(dir_digest, direct_f + children_files);
13871415
}
13881416

13891417
Ok(ResolvedTree {
13901418
file_digests,
13911419
dir_digests: seen_dirs,
13921420
subtree_bytes,
1421+
subtree_files,
13931422
})
13941423
}
13951424

0 commit comments

Comments
 (0)