Skip to content

Commit 2bab85b

Browse files
hyuknlfr-0531
authored andcommitted
[None][fix] Autotuner cache: merge instead of replace, atomic save (NVIDIA#14441)
Signed-off-by: Yukun He <23156053+hyukn@users.noreply.github.com>
1 parent 2aaaac4 commit 2bab85b

1 file changed

Lines changed: 99 additions & 20 deletions

File tree

tensorrt_llm/_torch/autotuner.py

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
362388
class 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

Comments
 (0)