@@ -359,6 +359,32 @@ def __str__(self) -> str:
359359 return stats_str
360360
361361
362+ @contextlib .contextmanager
363+ def _exclusive_cache_lock (lock_path : Path ):
364+ """Cross-node-correct exclusive lock used by save_cache/load_cache.
365+
366+ POSIX byte-range lock (`fcntl.lockf`) held on a SEPARATE lockfile
367+ (not the data file), which avoids the truncate / rename complications
368+ of locking the file you're also rewriting. The lockfile is persistent;
369+ the kernel releases the lock when the holding fd is closed — including
370+ on process death, so there is no stale-lock cleanup path to maintain.
371+
372+ Both readers and writers use LOCK_EX: read/write frequencies on the
373+ autotuner cache are similar and the simpler "always exclusive" model
374+ avoids reader/writer-priority pitfalls on networked filesystems.
375+ """
376+ lock_path .parent .mkdir (parents = True , exist_ok = True )
377+ fd = os .open (lock_path , os .O_CREAT | os .O_RDWR , 0o644 )
378+ try :
379+ fcntl .lockf (fd , fcntl .LOCK_EX )
380+ yield
381+ finally :
382+ try :
383+ fcntl .lockf (fd , fcntl .LOCK_UN )
384+ finally :
385+ os .close (fd )
386+
387+
362388class AutoTunerProfilingCache :
363389 """AutoTunerCache for caching profiling results.
364390
@@ -509,9 +535,28 @@ def save_cache(self, file_path: Union[str, Path], rank: int) -> None:
509535 Note:
510536 The cache is saved in JSON format which provides human-readable output.
511537 Some type information may be lost for complex tactic objects.
538+
539+ Concurrency model:
540+ * Cross-process mutual exclusion uses POSIX byte-range locks
541+ (`fcntl.lockf`) on a SEPARATE lockfile next to the data
542+ file. The lockfile is persistent (never unlinked); the
543+ kernel auto-releases the lock when the holding fd is
544+ closed, including on crash, so no stale-lock cleanup is
545+ needed. Both readers and writers take LOCK_EX — read/write
546+ frequencies are similar here and the simpler logic is
547+ worth more than read parallelism.
548+ * The data file itself is replaced atomically via
549+ (write-to-tmp + fsync + rename). A writer killed at any
550+ point leaves a discardable .tmp file; `file_path` is never
551+ partially written, so concurrent readers always see a
552+ fully-formed JSON document.
553+ * The on-disk rank-specific dict is MERGED (not replaced)
554+ with this writer's contribution so a concurrent writer
555+ that loaded earlier does not get its entries clobbered.
512556 """
513557 file_path = Path (file_path )
514558 file_path .parent .mkdir (parents = True , exist_ok = True )
559+ lock_path = self ._lock_path_for (file_path )
515560
516561 try :
517562 # Partition cache into shared (non-INDEPENDENT) and rank-specific (INDEPENDENT)
@@ -520,36 +565,68 @@ def save_cache(self, file_path: Union[str, Path], rank: int) -> None:
520565 serialized_shared_cache = self ._serialize_cache_data (shared_cache )
521566 serialized_rank_cache = self ._serialize_cache_data (rank_cache )
522567
523- with open (file_path , 'a+' ) as f :
524- fcntl .flock (f , fcntl .LOCK_EX )
525- f .seek (0 )
526- content = f .read ()
527- if content .strip ():
528- current_cache = json .loads (content )
529- else :
530- current_cache = {
531- "metadata" : self ._serialize_metadata (),
532- }
533- f .seek (0 )
534- f .truncate ()
568+ with _exclusive_cache_lock (lock_path ):
569+ current_cache = self ._read_existing_cache (file_path )
535570
536571 # Merge shared cache entries (non-INDEPENDENT ops)
537572 if self .SHARED_CACHE_KEY not in current_cache :
538573 current_cache [self .SHARED_CACHE_KEY ] = {}
539574 current_cache [self .SHARED_CACHE_KEY ].update (
540575 serialized_shared_cache )
541576
542- # Save rank-specific cache entries (INDEPENDENT ops)
543- current_cache [f"rank_{ rank } " ] = serialized_rank_cache
544-
545- json .dump (current_cache , f , indent = 2 , default = str )
577+ # Merge rank-specific cache entries (INDEPENDENT ops).
578+ # MUST be a merge (not assignment): a concurrent writer
579+ # that committed its rank_{rank} contribution between
580+ # this writer's load_cache and save_cache would otherwise
581+ # be silently dropped by an `=` assignment that re-writes
582+ # the slot with only this writer's in-memory cache.
583+ rank_key = f"rank_{ rank } "
584+ if rank_key not in current_cache :
585+ current_cache [rank_key ] = {}
586+ current_cache [rank_key ].update (serialized_rank_cache )
587+
588+ self ._atomic_write_json (file_path , current_cache )
546589 logger .info (
547590 f"[AutoTuner] Successfully saved cache to { file_path } using JSON format"
548591 )
549592 except Exception as e :
550593 logger .error (f"[AutoTuner] Failed to save cache with JSON: { e } " )
551594 raise
552595
596+ @staticmethod
597+ def _lock_path_for (file_path : Path ) -> Path :
598+ """Return the lockfile path that pairs with `file_path`."""
599+ return file_path .with_name (file_path .name + ".lock" )
600+
601+ def _read_existing_cache (self , file_path : Path ) -> Dict [str , Any ]:
602+ """Read the data file (best-effort) under the caller's lock."""
603+ if not file_path .exists ():
604+ return {"metadata" : self ._serialize_metadata ()}
605+ try :
606+ with open (file_path , "r" ) as f :
607+ content = f .read ()
608+ if not content .strip ():
609+ return {"metadata" : self ._serialize_metadata ()}
610+ return json .loads (content )
611+ except (FileNotFoundError , json .JSONDecodeError ) as e :
612+ # An earlier writer from a pre-fix version may have left a
613+ # corrupted file behind. Don't fail the current save — start
614+ # from a fresh dict and overwrite atomically.
615+ logger .warning (f"[AutoTuner] Could not parse existing cache at "
616+ f"{ file_path } : { e !r} ; starting from a fresh dict." )
617+ return {"metadata" : self ._serialize_metadata ()}
618+
619+ @staticmethod
620+ def _atomic_write_json (file_path : Path , payload : Dict [str , Any ]) -> None :
621+ """Write `payload` to `file_path` atomically (tmp + fsync + rename)."""
622+ tmp_path = file_path .with_name (
623+ f".{ file_path .name } .tmp.{ os .getpid ()} .{ time .time_ns ()} " )
624+ with open (tmp_path , "w" ) as f :
625+ json .dump (payload , f , indent = 2 , default = str )
626+ f .flush ()
627+ os .fsync (f .fileno ())
628+ os .rename (tmp_path , file_path )
629+
553630 def load_cache (self , file_path : Union [str , Path ], rank : int ) -> None :
554631 """Load the profiling cache from disk in JSON format.
555632
@@ -572,11 +649,13 @@ def load_cache(self, file_path: Union[str, Path], rank: int) -> None:
572649 if not file_path .exists ():
573650 raise FileNotFoundError (f"Cache file not found: { file_path } " )
574651
652+ lock_path = self ._lock_path_for (file_path )
575653 try :
576- with open (file_path , 'r' ) as f :
577- fcntl .flock (f , fcntl .LOCK_SH )
578- current_cache_contents = json .load (f )
579- self ._deserialize_metadata (current_cache_contents ["metadata" ])
654+ with _exclusive_cache_lock (lock_path ):
655+ with open (file_path , "r" ) as f :
656+ current_cache_contents = json .load (f )
657+ self ._deserialize_metadata (
658+ current_cache_contents .get ("metadata" , {}))
580659
581660 # Start with empty cache and independent ops set
582661 self .cache = {}
0 commit comments