@@ -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