Skip to content

Commit 7cad598

Browse files
committed
attribution_in_progress_end
1 parent 75681e0 commit 7cad598

2 files changed

Lines changed: 166 additions & 124 deletions

File tree

src/nvidia_resiliency_ext/attribution/log_analyzer/nvrx_logsage.py

Lines changed: 104 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import random
77
import re
88
import time
9+
from collections import deque
910
from typing import Any, Dict, Mapping, Optional, Tuple, Union
1011

1112
from langchain_core.output_parsers import StrOutputParser
@@ -521,6 +522,68 @@ def _with_exponential_backoff(llm_call, checkpoint_saved: bool) -> tuple[str, st
521522
return fallback
522523

523524

525+
class _ProgressiveLogState:
526+
"""Bounded per-path state for progressive (start -> end) log attribution.
527+
528+
Replaces an append-only per-poll list of 7-tuples (which retained raw lines,
529+
``ApplicationData``, and intermediate attribution objects for *every* poll)
530+
with only what the final history-aware attribution actually consumes:
531+
532+
* ``latest_offset`` - file offset to resume reading from
533+
* ``recent_chunks`` - rolling window of the last ``window`` polls' new lines
534+
* ``checkpoint_seen`` - OR-reduced ``checkpoint_saved`` across *all* polls
535+
* ``checkpoint_first_poll``- checkpoint observed on the very first poll
536+
* ``poll_count`` - number of polls reconciled (logging / truthiness)
537+
538+
Only the last ``window`` chunks of raw lines are retained, so memory stays
539+
bounded no matter how long the start phase polls. ``checkpoint_seen`` is
540+
tracked independently so a checkpoint that has scrolled out of the window
541+
(e.g. a boot-time checkpoint on the first poll) still flips the final
542+
attribution.
543+
544+
``__len__`` returns ``poll_count`` so an unused state (no polls) is falsy,
545+
preserving the previous "empty list is falsy" semantics at call sites that
546+
test ``job_inline_data_dict.get(path)``.
547+
"""
548+
549+
__slots__ = (
550+
"window",
551+
"latest_offset",
552+
"recent_chunks",
553+
"checkpoint_seen",
554+
"checkpoint_first_poll",
555+
"poll_count",
556+
)
557+
558+
def __init__(self, window: int) -> None:
559+
self.window = max(int(window), 1)
560+
self.latest_offset = 0
561+
self.recent_chunks: "deque[list[str]]" = deque(maxlen=self.window)
562+
self.checkpoint_seen = False
563+
self.checkpoint_first_poll = False
564+
self.poll_count = 0
565+
566+
def __len__(self) -> int:
567+
return self.poll_count
568+
569+
def reconcile(self, file_offset: int, new_lines: list[str], checkpoint_saved: bool) -> None:
570+
"""Fold one poll into the bounded state."""
571+
if checkpoint_saved:
572+
if self.poll_count == 0:
573+
self.checkpoint_first_poll = True
574+
self.checkpoint_seen = True
575+
self.latest_offset = file_offset
576+
self.recent_chunks.append(list(new_lines))
577+
self.poll_count += 1
578+
579+
def recent_lines(self) -> list[str]:
580+
"""Flatten the rolling window into a single list of lines."""
581+
lines: list[str] = []
582+
for chunk in self.recent_chunks:
583+
lines.extend(chunk)
584+
return lines
585+
586+
524587
class NVRxLogAnalyzer(NVRxAttribution):
525588
def __init__(self, args: Union[argparse.Namespace, Mapping[str, Any]]):
526589
from nvidia_resiliency_ext.attribution.api_keys import (
@@ -562,13 +625,26 @@ def __init__(self, args: Union[argparse.Namespace, Mapping[str, Any]]):
562625
def init_config(self) -> Dict[str, Any]:
563626
return dict(self._init_config)
564627

628+
def _history_window(self) -> int:
629+
"""Number of recent poll-chunks the end phase glues to the freshly read
630+
tail: ``int(logs_minutes_before_job_end / chunks_per_time)``, floored at 1.
631+
632+
Bounds :attr:`_ProgressiveLogState.recent_chunks` so progressive memory
633+
stays constant regardless of how long the start phase polls.
634+
"""
635+
if self.chunks_per_time:
636+
return max(int(self.logs_minutes_before_job_end / self.chunks_per_time), 1)
637+
return 1
638+
565639
async def analyze_logs_rt_start(self) -> dict[str, str | None]:
566640
"""Run the progressive-analysis start phase for the configured log path.
567641
568-
This is a non-result-producing phase: it polls the log file, accumulating
569-
per-poll attribution data into ``self.job_inline_data_dict[path]`` as a side
570-
effect for a later (history-aware) ``analyze_logs`` call. It does not return
571-
the attribution itself.
642+
This is a non-result-producing phase: it polls the log file, reconciling
643+
each poll into a bounded :class:`_ProgressiveLogState` at
644+
``self.job_inline_data_dict[path]`` (latest offset, a rolling window of
645+
recent lines, and an accumulated checkpoint flag) as a side effect for a
646+
later (history-aware) ``analyze_logs`` call. It does not return the
647+
attribution itself.
572648
573649
Returns:
574650
A :class:`~nvidia_resiliency_ext.attribution.orchestration.progressive.ProgressiveStartResult`
@@ -588,18 +664,13 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
588664

589665
if path not in self.temporal_cache_dict:
590666
self.temporal_cache_dict[path] = {}
591-
self.job_inline_data_dict.setdefault(path, [])
667+
state = self.job_inline_data_dict.get(path)
668+
if not isinstance(state, _ProgressiveLogState):
669+
state = _ProgressiveLogState(self._history_window())
670+
self.job_inline_data_dict[path] = state
592671
file_offset = 0
593-
log_lines: list[str] = []
594672
empty_logs_stop = self.stop_accumulating_count
595673

596-
application_log, attribution_raw_chunk, attribution_dict_chunk, hw_category_chunk = (
597-
None,
598-
None,
599-
None,
600-
None,
601-
)
602-
603674
while True:
604675
try:
605676
with open(path, 'r', encoding='utf-8') as f:
@@ -620,58 +691,34 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
620691
if empty_logs_stop <= 0:
621692
break
622693

623-
log_lines.extend(new_lines)
624-
attribution_list = []
625-
694+
# Only ``checkpoint_saved`` (set by error extraction) is consumed
695+
# from this poll; reconcile it plus the offset and raw lines into the
696+
# bounded state. No per-poll get_attribution: its output was never read.
626697
chunk_data = _retry_return_application_errors_rt(
627698
llm, new_lines, cache_dict, self.temporal_cache_dict[path]
628699
)
629-
app_data = chunk_data
630-
if chunk_data.application_errors_list_full:
631-
(
632-
application_log,
633-
attribution_raw_chunk,
634-
attribution_dict_chunk,
635-
hw_category_chunk,
636-
) = get_attribution(llm, app_data, True)
637-
attribution_list.append(attribution_raw_chunk)
638-
639-
self.job_inline_data_dict[path].append(
640-
(
641-
file_offset,
642-
new_lines,
643-
chunk_data,
644-
application_log,
645-
attribution_raw_chunk,
646-
attribution_dict_chunk,
647-
hw_category_chunk,
648-
)
649-
)
700+
checkpoint_saved = bool(getattr(chunk_data, "checkpoint_saved", False))
701+
state.reconcile(file_offset, new_lines, checkpoint_saved)
650702
logger.info(
651-
f"[ckpt] analyze_logs_rt_start poll #"
652-
f"{len(self.job_inline_data_dict[path])}: "
653-
f"chunk_data.checkpoint_saved="
654-
f"{getattr(chunk_data, 'checkpoint_saved', False)}",
703+
f"[ckpt] analyze_logs_rt_start poll #{state.poll_count}: "
704+
f"chunk_data.checkpoint_saved={checkpoint_saved}",
655705
)
656706

657707
await asyncio.sleep(self.chunks_per_time * 60)
658708

659709
return ProgressiveStartResult(
660710
status=PROGRESSIVE_STATUS_STARTED,
661-
message=(
662-
f"progressive analysis accumulated "
663-
f"{len(self.job_inline_data_dict[path])} chunk(s) for {path}"
664-
),
711+
message=(f"progressive analysis accumulated {state.poll_count} chunk(s) for {path}"),
665712
).as_payload()
666713

667714
async def analyze_logs(self) -> list[ApplicationData]:
668715
"""
669716
Analyzes the logs and returns the application errors.
670717
671718
If progressive analysis (``analyze_logs_rt_start``) has accumulated
672-
per-poll history for this log path in ``job_inline_data_dict``, read
673-
only the tail beyond the last polled offset, glue it onto the last N
674-
history chunks, and run a single extraction over the combined chunk
719+
bounded state for this log path in ``job_inline_data_dict``, read only
720+
the tail beyond the last polled offset, glue it onto the rolling window
721+
of recent chunks, and run a single extraction over the combined chunk
675722
— returning a one-element list.
676723
677724
Otherwise read the whole file, optionally exclude nvrx lines, chunk
@@ -680,8 +727,8 @@ async def analyze_logs(self) -> list[ApplicationData]:
680727
cfg = effective_run_or_init_config(self._init_config)
681728
path = cfg["log_path"]
682729

683-
history = self.job_inline_data_dict.get(path)
684-
if history:
730+
state = self.job_inline_data_dict.get(path)
731+
if isinstance(state, _ProgressiveLogState) and state.poll_count:
685732
cycle_counter = int(cfg.get("cycle_counter", 0))
686733
cycle_counter_key = _cycle_counter_key(path)
687734
if cycle_counter == 0:
@@ -690,7 +737,7 @@ async def analyze_logs(self) -> list[ApplicationData]:
690737
if path not in self.temporal_cache_dict:
691738
self.temporal_cache_dict[path] = {}
692739

693-
file_offset = history[-1][0]
740+
file_offset = state.latest_offset
694741
try:
695742
with open(path, 'r', encoding='utf-8') as f:
696743
f.seek(file_offset)
@@ -700,20 +747,16 @@ async def analyze_logs(self) -> list[ApplicationData]:
700747
f.seek(file_offset)
701748
new_lines = f.readlines()
702749

703-
num_chunks = int(self.logs_minutes_before_job_end / self.chunks_per_time)
704-
chunk: list[str] = []
705-
for item in history[-num_chunks:]:
706-
chunk = chunk + item[1]
707-
chunk = chunk + new_lines
750+
chunk = state.recent_lines() + new_lines
708751

709752
chunk_data = _retry_return_application_errors_rt(
710753
self.llm, chunk, self.lru_cache, self.temporal_cache_dict[path]
711754
)
712-
chunk_data.checkpoint_saved = any(item[2].checkpoint_saved for item in history)
755+
chunk_data.checkpoint_saved = state.checkpoint_seen
713756
logger.info(
714757
f"[ckpt] analyze_logs (history-aware): OR-reduced "
715758
f"checkpoint_saved={chunk_data.checkpoint_saved} "
716-
f"across {len(history)} history entries",
759+
f"across {state.poll_count} polls",
717760
)
718761
return [chunk_data]
719762

@@ -776,6 +819,9 @@ async def llm_analyze(self, output_list: list[ApplicationData]) -> list[LogSageC
776819
path = cfg.get("log_path")
777820
if path and self.job_inline_data_dict.get(path) and len(output_list) == 1:
778821
rt_result = self._streaming_attribution(output_list[0], cfg, path)
822+
# Final attribution has consumed the progressive state; drop the
823+
# per-path entry so it does not accumulate across long-running jobs.
824+
self.job_inline_data_dict.pop(path, None)
779825
if rt_result is None:
780826
return []
781827
attribution_str = str(rt_result.attribution)

0 commit comments

Comments
 (0)