Skip to content

Commit 7c72273

Browse files
authored
Merge pull request #150 from OrderLab/codex/parallel-infer-integration
Reconcile parallel inference with main for the optimization stack
2 parents ed54c7d + a3c7d39 commit 7c72273

15 files changed

Lines changed: 1136 additions & 90 deletions

‎tests/test_inference_sampling.py‎

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
"""Regression coverage for task-local sampling, including actual relation paths."""
2+
3+
import importlib
4+
import json
5+
import multiprocessing
6+
import os
7+
import random
8+
import subprocess
9+
import sys
10+
import threading
11+
import unittest
12+
from concurrent.futures import ProcessPoolExecutor
13+
from dataclasses import dataclass
14+
from pathlib import Path
15+
from unittest.mock import patch
16+
17+
from traincheck.infer_engine import (
18+
InferEngine,
19+
_find_precondition_task,
20+
_generate_hypothesis_task,
21+
_genhypo_worker_task,
22+
_hypothesis_sampling_key,
23+
_precond_worker_task,
24+
)
25+
from traincheck.inference_sampling import sampling_rng, sampling_task
26+
from traincheck.invariant import relation_pool
27+
from traincheck.invariant.base_cls import (
28+
APIParam,
29+
Example,
30+
ExampleList,
31+
Hypothesis,
32+
Invariant,
33+
UnconditionalPrecondition,
34+
)
35+
from traincheck.invariant.contain_relation import (
36+
PARENT_GROUP_NAME,
37+
APIContainRelation,
38+
cache_events_scanner,
39+
prune_func_call,
40+
)
41+
from traincheck.parallel import config_snapshot, worker_init
42+
from traincheck.trace.trace_pandas import TracePandas
43+
from traincheck.trace.types import FuncCallEvent
44+
45+
PARENT = "torch.nn.modules.activation.ReLU.forward"
46+
CHILD = "torch.nn.modules.dropout.Dropout.forward"
47+
48+
49+
def nested_call_trace(prefix, calls=1005):
50+
"""Complete synthetic events, not training: >1000 calls exercises both samplers."""
51+
rows = []
52+
for index in range(calls):
53+
events = [
54+
(PARENT, "parent", "pre"),
55+
(CHILD, "child", "pre"),
56+
(CHILD, "child", "post"),
57+
(PARENT, "parent", "post"),
58+
]
59+
for offset, (function, name, kind) in enumerate(events):
60+
row = {
61+
"time": index * 10 + offset + 1,
62+
"func_call_id": f"{prefix}-{name}-{index}",
63+
"function": function,
64+
"type": f"function_call ({kind})",
65+
"process_id": 1,
66+
"thread_id": 1,
67+
"is_bound_method": True,
68+
"obj_id": 1,
69+
"meta_vars": {},
70+
"args": {"0": {"object": {}}, "1": {"float": 1.0}},
71+
"kwargs": {},
72+
}
73+
if kind == "post":
74+
row["return_values"] = {"float": 1.0}
75+
rows.append(row)
76+
return TracePandas(rows)
77+
78+
79+
def hypothesis_evidence(hypotheses):
80+
"""Compare exact retained call IDs/order as well as relation and parameters."""
81+
results = []
82+
for hypothesis in hypotheses:
83+
84+
def examples(items):
85+
return [
86+
{
87+
name: [row["func_call_id"] for row in rows]
88+
for name, rows in sorted(example.trace_groups.items())
89+
}
90+
for example in items.examples
91+
]
92+
93+
results.append(
94+
{
95+
"key": _hypothesis_sampling_key(hypothesis),
96+
"positive": examples(hypothesis.positive_examples),
97+
"negative": examples(hypothesis.negative_examples),
98+
}
99+
)
100+
return sorted(results, key=lambda item: item["key"])
101+
102+
103+
def simple_hypothesis(positive_count=3, negative_count=0):
104+
def examples(count, prefix):
105+
# Direct construction deliberately reaches the >5000 precondition branch;
106+
# add_example's separate reservoir would otherwise cap the input first.
107+
return ExampleList.from_iterable_of_examples(
108+
[
109+
Example(
110+
{PARENT_GROUP_NAME: [{"id": f"{prefix}-{i}", "function": PARENT}]}
111+
)
112+
for i in range(count)
113+
],
114+
{PARENT_GROUP_NAME},
115+
)
116+
117+
return Hypothesis(
118+
Invariant(
119+
APIContainRelation, [APIParam(PARENT), APIParam(CHILD)], None, "fixture"
120+
),
121+
examples(positive_count, "positive"),
122+
examples(negative_count, "negative"),
123+
)
124+
125+
126+
class SamplingScopeTests(unittest.TestCase):
127+
def test_reproducible_keys_and_untouched_global_rng(self):
128+
before = random.getstate()
129+
self.assertIs(sampling_rng(), random)
130+
with sampling_task("generate", 0, "relation"):
131+
first = [sampling_rng().random() for _ in range(20)]
132+
self.assertEqual(random.getstate(), before)
133+
random.random()
134+
after_noise = random.getstate()
135+
with sampling_task("generate", 0, "relation"):
136+
second = [sampling_rng().random() for _ in range(20)]
137+
with sampling_task("generate", 1, "relation"):
138+
other = [sampling_rng().random() for _ in range(20)]
139+
self.assertEqual(first, second)
140+
self.assertNotEqual(first, other)
141+
self.assertEqual(random.getstate(), after_noise)
142+
143+
def test_nested_scopes_restore_on_frozen_exception(self):
144+
@dataclass(frozen=True)
145+
class FrozenError(Exception):
146+
reason: str
147+
148+
with sampling_task("outer") as expected_rng:
149+
expected = [expected_rng.random() for _ in range(2)]
150+
error = FrozenError("expected")
151+
before = random.getstate()
152+
with sampling_task("outer") as outer:
153+
first = sampling_rng().random()
154+
try:
155+
with sampling_task("inner"):
156+
sampling_rng().random()
157+
raise error
158+
except FrozenError as caught:
159+
self.assertIs(caught, error)
160+
self.assertIs(sampling_rng(), outer)
161+
second = sampling_rng().random()
162+
self.assertEqual([first, second], expected)
163+
self.assertEqual(random.getstate(), before)
164+
self.assertIs(sampling_rng(), random)
165+
166+
def test_context_is_thread_local(self):
167+
errors = []
168+
values = []
169+
170+
def worker():
171+
try:
172+
self.assertIs(sampling_rng(), random)
173+
with sampling_task("worker"):
174+
values.append(sampling_rng().random())
175+
except BaseException as error:
176+
errors.append(error)
177+
178+
with sampling_task("parent") as parent:
179+
thread = threading.Thread(target=worker)
180+
thread.start()
181+
thread.join(5)
182+
self.assertFalse(thread.is_alive())
183+
self.assertIs(sampling_rng(), parent)
184+
self.assertEqual(errors, [])
185+
self.assertEqual(len(values), 1)
186+
187+
def test_seed_does_not_depend_on_python_hash_seed(self):
188+
script = (
189+
"import json; from traincheck.inference_sampling import sampling_task,sampling_rng; "
190+
"\nwith sampling_task('generate', 4, {'b': 2, 'a': 1}): "
191+
"print(json.dumps([sampling_rng().random() for _ in range(8)]))"
192+
)
193+
root = str(Path(__file__).resolve().parents[1])
194+
outputs = []
195+
for seed in ("1", "98765"):
196+
env = dict(os.environ, PYTHONHASHSEED=seed, PYTHONPATH=root)
197+
outputs.append(
198+
subprocess.check_output(
199+
[sys.executable, "-c", script], env=env, text=True
200+
)
201+
)
202+
self.assertEqual(outputs[0], outputs[1])
203+
204+
def test_actual_pruning_is_nontrivial_and_task_deterministic(self):
205+
def retained(identity):
206+
sizes, types, kept = [], [], []
207+
with sampling_task("pruning", identity):
208+
for index in range(1500):
209+
if not prune_func_call(1500, FuncCallEvent, sizes, types):
210+
kept.append(index)
211+
sizes.append(1)
212+
types.append(FuncCallEvent)
213+
return kept
214+
215+
first = retained("same")
216+
for _ in range(101):
217+
random.random()
218+
self.assertEqual(first, retained("same"))
219+
self.assertNotEqual(first, retained("different"))
220+
self.assertGreater(len(first), 10)
221+
self.assertLess(len(first), 1500)
222+
223+
224+
class InferenceTaskTests(unittest.TestCase):
225+
@classmethod
226+
def setUpClass(cls):
227+
cls.traces = [nested_call_trace("trace0"), nested_call_trace("trace1")]
228+
229+
def setUp(self):
230+
cache_events_scanner.clear()
231+
232+
def test_hypothesis_key_ignores_mutable_results_but_tracks_semantics(self):
233+
hypothesis = simple_hypothesis()
234+
key = _hypothesis_sampling_key(hypothesis)
235+
hypothesis.invariant.num_positive_examples = 900
236+
hypothesis.invariant.num_negative_examples = 8
237+
hypothesis.invariant.text_description = "different display text"
238+
hypothesis.invariant.precondition = object()
239+
self.assertEqual(_hypothesis_sampling_key(hypothesis), key)
240+
hypothesis.valid_only_for_stages = True
241+
hypothesis.valid_stages = {"training", "evaluation"}
242+
staged = _hypothesis_sampling_key(hypothesis)
243+
self.assertNotEqual(staged, key)
244+
hypothesis.valid_stages = set(["evaluation", "training"])
245+
self.assertEqual(_hypothesis_sampling_key(hypothesis), staged)
246+
hypothesis.invariant.params.reverse()
247+
self.assertNotEqual(_hypothesis_sampling_key(hypothesis), staged)
248+
249+
def test_real_relation_retained_examples_repeat_despite_ambient_rng(self):
250+
before = random.getstate()
251+
hypotheses = _generate_hypothesis_task(0, APIContainRelation, self.traces[0])
252+
self.assertEqual(random.getstate(), before)
253+
first = hypothesis_evidence(hypotheses)
254+
self.assertEqual(len(first), 1)
255+
self.assertGreater(len(first[0]["positive"]), 10)
256+
self.assertLess(len(first[0]["positive"]), 120)
257+
for _ in range(77):
258+
random.random()
259+
second = hypothesis_evidence(
260+
_generate_hypothesis_task(0, APIContainRelation, self.traces[0])
261+
)
262+
self.assertEqual(first, second)
263+
264+
def test_engine_serial_dispatches_each_relation_through_shared_task(self):
265+
disabled = [
266+
relation for relation in relation_pool if relation is not APIContainRelation
267+
]
268+
engine = InferEngine(self.traces, disabled_relations=disabled, num_workers=1)
269+
with patch(
270+
"traincheck.infer_engine._generate_hypothesis_task",
271+
wraps=_generate_hypothesis_task,
272+
) as task:
273+
hypotheses = engine.generate_hypothesis()
274+
self.assertEqual(task.call_count, 2)
275+
self.assertEqual([call.args[0] for call in task.call_args_list], [0, 1])
276+
self.assertEqual(len(hypotheses), 1)
277+
self.assertEqual(next(iter(hypotheses.values())), [0, 1])
278+
279+
def test_actual_relation_matches_process_counts_orders_and_start_methods(self):
280+
expected = {
281+
index: hypothesis_evidence(
282+
_generate_hypothesis_task(index, APIContainRelation, trace)
283+
)
284+
for index, trace in enumerate(self.traces)
285+
}
286+
methods = [
287+
method
288+
for method in ("spawn", "fork")
289+
if method in multiprocessing.get_all_start_methods()
290+
]
291+
self.assertTrue(methods)
292+
for method in methods:
293+
for workers in (1, 2, 4):
294+
with self.subTest(start_method=method, workers=workers):
295+
# Repeated tasks consume distinct worker histories. Reversing
296+
# dispatch changes assignment without changing semantic keys.
297+
tasks = [(index, APIContainRelation) for index in (1, 0, 1, 0)]
298+
with ProcessPoolExecutor(
299+
max_workers=workers,
300+
mp_context=multiprocessing.get_context(method),
301+
initializer=worker_init,
302+
initargs=(self.traces, config_snapshot()),
303+
) as pool:
304+
futures = [
305+
pool.submit(_genhypo_worker_task, task) for task in tasks
306+
]
307+
for future in futures:
308+
index, relation, hypotheses = future.result(timeout=90)
309+
self.assertIs(relation, APIContainRelation)
310+
self.assertEqual(
311+
hypothesis_evidence(hypotheses), expected[index]
312+
)
313+
314+
def test_precondition_over_5000_preserves_legacy_samples_without_global_seed(self):
315+
precondition = importlib.import_module("traincheck.invariant.precondition")
316+
hypothesis = simple_hypothesis(5007, 5011)
317+
captured = []
318+
319+
def solve(positive, negative, traces, keys_to_skip):
320+
captured.append(
321+
(
322+
[item[0]["id"] for item in positive],
323+
[item[0]["id"] for item in negative],
324+
)
325+
)
326+
return [UnconditionalPrecondition()]
327+
328+
legacy_rng = random.Random(42)
329+
expected = (
330+
legacy_rng.sample([f"positive-{i}" for i in range(5007)], 5000),
331+
legacy_rng.sample([f"negative-{i}" for i in range(5011)], 5000),
332+
)
333+
before = random.getstate()
334+
with patch.object(
335+
precondition, "find_precondition_from_single_group", side_effect=solve
336+
):
337+
result = _find_precondition_task(
338+
hypothesis, [], _hypothesis_sampling_key(hypothesis)
339+
)
340+
self.assertIsNotNone(result)
341+
self.assertEqual(captured, [expected])
342+
self.assertEqual(random.getstate(), before)
343+
344+
def test_precondition_worker_dispatch_matches_serial_key(self):
345+
hypothesis = simple_hypothesis()
346+
key = _hypothesis_sampling_key(hypothesis)
347+
worker_init([], config_snapshot())
348+
349+
def result(*args):
350+
return [sampling_rng().random() for _ in range(6)]
351+
352+
with patch("traincheck.infer_engine.find_precondition", side_effect=result):
353+
expected = _find_precondition_task(hypothesis, [], key)
354+
random.random()
355+
actual = _precond_worker_task((key, hypothesis))
356+
self.assertEqual(actual, expected)
357+
358+
359+
if __name__ == "__main__":
360+
unittest.main()

0 commit comments

Comments
 (0)