Skip to content

Commit eebfba5

Browse files
andre15silvaclaude
andcommitted
fix(tool-nll): use KV-cache chunked forward pass to prevent OOM and GPU idle termination
Replaces the batching approach with chunked processing that accumulates past_key_values across chunks (same pattern as hf_extract_hidden_states_chunked). Bounds peak GPU memory to O(chunk_size × vocab_size) for arbitrarily long sequences, and keeps the GPU continuously busy to satisfy NSC's 90W power floor. Also fixes test_chunked_matches_full_pass (token IDs were out-of-vocab range). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 441d855 commit eebfba5

3 files changed

Lines changed: 164 additions & 21 deletions

File tree

build_tool_nll_index.py

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,94 @@ def compute_trajectory_nll(
7171
return turn_nll
7272

7373

74+
def _tool_output_ranges(
75+
segments: list[dict],
76+
step_segment_indices: list[int],
77+
) -> dict[int, tuple[int, int]]:
78+
"""Return {turn_k: (start_token, end_token)} for each tool output segment."""
79+
ranges: dict[int, tuple[int, int]] = {}
80+
for k, asst_seg_idx in enumerate(step_segment_indices):
81+
if k == 0 or asst_seg_idx == 0:
82+
continue
83+
tool_seg = segments[asst_seg_idx - 1]
84+
if tool_seg.get("role") != "user":
85+
continue
86+
start, end = tool_seg["start_token"], tool_seg["end_token"]
87+
if end > start and start > 0:
88+
ranges[k] = (start, end)
89+
return ranges
90+
91+
92+
def compute_trajectory_nll_chunked(
93+
token_ids: list[int],
94+
segments: list[dict],
95+
step_segment_indices: list[int],
96+
model,
97+
device,
98+
chunk_size: int,
99+
) -> dict[int, float]:
100+
"""KV-cache chunked version of compute_trajectory_nll.
101+
102+
Identical semantics to compute_trajectory_nll but processes the sequence in
103+
chunks of chunk_size tokens (with accumulated KV cache), so peak GPU memory is
104+
O(chunk_size × vocab_size) rather than O(seq_len × vocab_size). Handles
105+
arbitrarily long sequences without OOM.
106+
"""
107+
turn_ranges = _tool_output_ranges(segments, step_segment_indices)
108+
if not turn_ranges:
109+
return {}
110+
111+
seq_len = len(token_ids)
112+
input_ids = torch.tensor([token_ids], dtype=torch.long, device=device)
113+
# Accumulate per-token NLL values keyed by turn
114+
turn_nll_values: dict[int, list[float]] = {k: [] for k in turn_ranges}
115+
past_kv = None
116+
117+
for chunk_start in range(0, seq_len, chunk_size):
118+
chunk_end = min(chunk_start + chunk_size, seq_len)
119+
chunk_input = input_ids[:, chunk_start:chunk_end]
120+
# Attention mask must span past (cached) + current tokens
121+
chunk_attn = torch.ones(1, chunk_end, dtype=torch.long, device=device)
122+
123+
with torch.no_grad():
124+
out = model(
125+
input_ids=chunk_input,
126+
attention_mask=chunk_attn,
127+
past_key_values=past_kv,
128+
use_cache=True,
129+
output_hidden_states=False,
130+
)
131+
132+
chunk_logits = out.logits[0] # [chunk_len, vocab_size], on GPU
133+
134+
for k, (tok_start, tok_end) in turn_ranges.items():
135+
# logits[j-1] predicts token[j]: need logit positions [tok_start-1, tok_end-1)
136+
logit_start = tok_start - 1
137+
logit_end = tok_end - 1
138+
# Intersect with the current chunk's logit positions [chunk_start, chunk_end)
139+
overlap_start = max(logit_start, chunk_start)
140+
overlap_end = min(logit_end, chunk_end)
141+
if overlap_start >= overlap_end:
142+
continue
143+
local_start = overlap_start - chunk_start
144+
local_end = overlap_end - chunk_start
145+
logit_slice = chunk_logits[local_start:local_end] # [n, vocab]
146+
# Tokens predicted: token positions [overlap_start+1, overlap_end+1)
147+
actual = torch.tensor(
148+
token_ids[overlap_start + 1 : overlap_end + 1],
149+
dtype=torch.long, device=device,
150+
)
151+
n = actual.shape[0]
152+
log_probs = torch.log_softmax(logit_slice.float(), dim=-1)
153+
nll_vals = -log_probs[torch.arange(n, device=device), actual]
154+
turn_nll_values[k].extend(nll_vals.tolist())
155+
156+
past_kv = out.past_key_values
157+
del out, chunk_logits
158+
159+
return {k: sum(v) / len(v) for k, v in turn_nll_values.items() if v}
160+
161+
74162
def _load_model_adapter(adapter_name: str):
75163
if adapter_name == "laguna":
76164
from src.models.laguna import LagunaAdapter
@@ -93,6 +181,9 @@ def main() -> None:
93181
parser.add_argument("--generation-config", required=True)
94182
parser.add_argument("--traj-dir", required=True, help="Directory of trajectory JSON files")
95183
parser.add_argument("--output", required=True, help="Output .pt path")
184+
parser.add_argument("--chunk-size", type=int, default=4096,
185+
help="Tokens per forward-pass chunk (KV-cache chunking); "
186+
"bounds peak GPU memory to O(chunk_size × vocab_size)")
96187
parser.add_argument("--shard-rank", type=int, default=0)
97188
parser.add_argument("--num-shards", type=int, default=1)
98189
args = parser.parse_args()
@@ -101,15 +192,14 @@ def main() -> None:
101192
gen_config: GenerationConfig = load_config(args.generation_config, GenerationConfig)
102193

103194
out_path = Path(args.output)
104-
# If sharded, accumulate into a shard-specific temp file; merge manually afterwards.
105-
# For simplicity we write the whole shard's results to the output path (caller merges).
106195
if args.num_shards > 1:
107196
out_path = out_path.with_suffix(f".shard{args.shard_rank}.pt")
108197

109198
print(f"Loading trajectories from {args.traj_dir}...")
110199
all_trajs = load_trajectories(args.traj_dir)
111200
trajs = all_trajs[args.shard_rank::args.num_shards]
112-
print(f" {len(trajs)} trajectories (shard {args.shard_rank}/{args.num_shards})")
201+
print(f" {len(trajs)} trajectories (shard {args.shard_rank}/{args.num_shards}), "
202+
f"chunk_size={args.chunk_size}")
113203

114204
model_adapter = _load_model_adapter(model_config.adapter)
115205
model_adapter.load_for_extraction(model_config, gen_config)
@@ -118,25 +208,16 @@ def main() -> None:
118208
index: dict[str, dict[int, float]] = {}
119209

120210
for i, traj in enumerate(trajs):
121-
print(f" [{i + 1}/{len(trajs)}] {traj.sample_id} ({len(traj.token_ids)} tokens)...", flush=True)
122-
input_ids = torch.tensor([traj.token_ids], dtype=torch.long, device=device)
123-
try:
124-
with torch.no_grad():
125-
out = model_adapter._model(input_ids=input_ids, output_hidden_states=False)
126-
logits = out.logits[0].cpu() # [seq_len, vocab_size] — move to CPU immediately
127-
del out
128-
torch.cuda.empty_cache()
129-
except torch.cuda.OutOfMemoryError:
130-
torch.cuda.empty_cache()
131-
print(f" OOM — skipping {traj.sample_id}")
132-
continue
133-
134-
turn_nll = compute_trajectory_nll(
135-
traj.token_ids, traj.segments, traj.step_segment_indices, logits
211+
n_chunks = (len(traj.token_ids) + args.chunk_size - 1) // args.chunk_size
212+
print(f" [{i + 1}/{len(trajs)}] {traj.sample_id} "
213+
f"({len(traj.token_ids)} tokens, {n_chunks} chunks)...", flush=True)
214+
turn_nll = compute_trajectory_nll_chunked(
215+
traj.token_ids, traj.segments, traj.step_segment_indices,
216+
model_adapter._model, device, args.chunk_size,
136217
)
137-
del logits
138218
index[traj.sample_id] = turn_nll
139-
print(f" {len(turn_nll)} turns with tool NLL (out of {len(traj.step_segment_indices)} total turns)")
219+
print(f" {len(turn_nll)} turns with NLL "
220+
f"(out of {len(traj.step_segment_indices)} total turns)")
140221

141222
out_path.parent.mkdir(parents=True, exist_ok=True)
142223
torch.save(index, out_path)

slurm/build_tool_nll.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5151
uv run python build_tool_nll_index.py \
5252
--shard-rank "$RANK" \
5353
--num-shards "$NUM_SHARDS" \
54+
--chunk-size 4096 \
5455
"$@"

tests/test_tool_nll.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55
import torch
66

7-
from build_tool_nll_index import compute_trajectory_nll
7+
from build_tool_nll_index import compute_trajectory_nll, compute_trajectory_nll_chunked
88

99

1010
# ---------------------------------------------------------------------------
@@ -158,6 +158,67 @@ def test_nll_multiple_turns():
158158
assert set(result.keys()) == {1, 2, 3}
159159

160160

161+
def test_chunked_matches_full_pass():
162+
"""compute_trajectory_nll_chunked must give the same result as the full-pass version."""
163+
vocab_size = 8
164+
seq_len = 40
165+
turns = [
166+
("system", 0, 3),
167+
("user", 3, 7),
168+
("assistant", 7, 12), # turn 0
169+
("user", 12, 18), # tool output → turn 1 (6 tokens)
170+
("assistant", 18, 22), # turn 1
171+
("user", 22, 30), # tool output → turn 2 (8 tokens)
172+
("assistant", 30, 35), # turn 2
173+
("user", 35, 40), # tool output → turn 3 (5 tokens) — no following turn
174+
]
175+
# Add a turn 3 so the last tool output gets included
176+
turns.append(("assistant", 40, 40)) # dummy empty turn 3
177+
178+
token_ids = [i % vocab_size for i in range(seq_len)] # IDs within vocab
179+
segments, step_segs = _make_segments_and_steps(turns)
180+
181+
# Random logits — deterministic via seed
182+
torch.manual_seed(7)
183+
logits = torch.randn(seq_len, vocab_size)
184+
185+
# Full-pass reference
186+
expected = compute_trajectory_nll(token_ids, segments, step_segs, logits)
187+
188+
# Build a tiny fake model that returns pre-set logits
189+
class _FakeModel(torch.nn.Module):
190+
def __init__(self, full_logits):
191+
super().__init__()
192+
self._logits = full_logits # [seq_len, vocab]
193+
194+
def forward(self, input_ids, attention_mask=None, past_key_values=None,
195+
use_cache=False, output_hidden_states=False):
196+
start = 0 if past_key_values is None else past_key_values
197+
end = start + input_ids.shape[1]
198+
chunk_logits = self._logits[start:end].unsqueeze(0) # [1, chunk, vocab]
199+
200+
class _Out:
201+
pass
202+
o = _Out()
203+
o.logits = chunk_logits
204+
o.past_key_values = end # reuse as a simple int offset
205+
return o
206+
207+
model = _FakeModel(logits)
208+
device = torch.device("cpu")
209+
210+
# chunk_size=5 forces multiple chunks over the 40-token sequence
211+
chunked = compute_trajectory_nll_chunked(
212+
token_ids, segments, step_segs, model, device, chunk_size=5
213+
)
214+
215+
assert set(chunked.keys()) == set(expected.keys()), \
216+
f"Turn keys differ: chunked={set(chunked.keys())} expected={set(expected.keys())}"
217+
for k in expected:
218+
assert abs(chunked[k] - expected[k]) < 1e-4, \
219+
f"Turn {k}: chunked={chunked[k]:.6f} vs full={expected[k]:.6f}"
220+
221+
161222
# ---------------------------------------------------------------------------
162223
# Tests for Brier score and Spearman correlation logic
163224
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)