Skip to content

autoresearch: generate and gate non-catalog vLLM optimization candidates#56

Open
nicholaslawrence-hub wants to merge 25 commits into
mainfrom
autoresearch-clean
Open

autoresearch: generate and gate non-catalog vLLM optimization candidates#56
nicholaslawrence-hub wants to merge 25 commits into
mainfrom
autoresearch-clean

Conversation

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator

Changes

  • Add gitm.agents.autoresearch

    • bottleneck classification
    • largest-residual targeting
    • table, generated, stochastic, and fallback proposer interfaces
    • vLLM EngineArgs knob discovery and value-grid generation
    • end-to-end autoresearch result reporting
  • Wire autoresearch into gitm.scheduler.loop as Phase 4b

    • runs after catalog interventions when budget remains
    • reuses the catalog gate/rollback path
    • writes autoresearch.json
    • adds autoresearch claims to the report
    • reports bottleneck_class and n_autoresearch
  • Add docs in docs/autoresearch.md

  • Add tests for autoresearch generation, targeting, gating, and loop integration

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -19,6 +19,7 @@
     FallbackProposer,
     TableProposer,
     autoresearch,
     classify_bottleneck,
 )
 from gitm.agents.policy import Policy, select_interventions
 from gitm.kernels.library import load_library
+from gitm.kernels.spec import knob_kind
 from gitm.optimizer.apply import (
     Applicator,
     DryRunApplicator,

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Code Review: gitm/agents/autoresearch.py + loop integration


🐛 Bugs

classify_bottleneck: memcpy fraction denominator is wrong

memcpy_frac = len(memcpys) / (len(memcpys) + len(kernels))

This counts memcpys as a fraction of total events, but _SC_THRESHOLD and the serialized-fraction logic are computed over kernels only. A trace with 1 kernel and 4 memcpys gives memcpy_frac = 0.8 — arguably correct by intent, but it means a single heavy compute kernel with lots of transfers can never classify as compute_bound even if the kernel dominates runtime. More critically, len(kernels) here is the GPU-kernel list from trace.kernels(), while trace.events includes all event types. If trace.events contains non-memcpy, non-kernel events (e.g. synchronizations), the denominator is understated, inflating memcpy_frac. Consider using len(trace.events) consistently or filtering to just {memcpy, kernel} events.

largest_residual: max() on empty by_op after filtering

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

by_op can only be empty if res.per_kernel is empty (guarded above), so this is safe — but if upstream ever passes a Residuals with per_kernel=[] that somehow bypasses the early return (e.g. after future refactoring), this raises ValueError. A defensive if not by_op: return None before the max would be cheap insurance.

_value_grid: int grid can silently return empty for zero-default knobs

base = d if isinstance(d, int | float) and d not in (0, False) else 1

False is == 0 in Python, so default=False on an "int" knob (shouldn't exist but can happen via annotation coercion) sets base=1 — fine. However, d not in (0, False) uses == equality, so default=0.0 also hits the fallback. This is intentional (the comment says "nonsensical"), but it's a subtle trap: a knob with default=0 gets base=1, producing grid values [1, 2, 4] regardless. If 0 is a meaningful default (e.g. "disabled"), those grid values may be wrong. This should at minimum be documented on Knob.

StochasticProposer.propose: deduplication loop can silently under-sample

for _ in range(self._n):
    ...
    if (knob.name, value) in seen:
        continue

When the knob surface is small (e.g. one bool knob with one grid value), nearly every draw after the first is a duplicate, and the loop silently returns far fewer than n_samples candidates with no warning. The caller has no way to know they got 1 sample instead of 6. Consider retrying up to a bounded attempt count, or documenting that n_samples is a maximum.

loop.py Phase 4b: trace and res may be unbound

ar_run = autoresearch(
    trace,
    ...
    residuals=res,
    ...
)

trace and res are assigned earlier in the loop body, but if an earlier phase raises and is caught, or if the loop structure changes, these could be unbound. The else branch only assigns ar_run; ar_run.bottleneck_class is then referenced unconditionally in the summary dict. If autoresearch itself raises (e.g. trace is malformed), ar_run is unbound and the summary write fails. Consider initializing ar_run = AutoresearchRun(bottleneck_class="unknown", results=[]) before the budget check.


🔒 Security

autoresearch.json write has no atomic/safe-write pattern

(run_dir / "autoresearch.json").write_text(json.dumps(...))

A crash mid-write leaves a truncated JSON file. Downstream readers (e.g. dashboards, the next loop iteration) will fail to parse it. Use a write-to-temp-then-rename pattern:

tmp = run_dir / "autoresearch.json.tmp"
tmp.write_text(json.dumps(...))
tmp.rename(run_dir / "autoresearch.json")

⚡ Performance

_engine_arg_knobs() is called on every propose() invocation
VLLMKnobSource.knobs()_engine_arg_knobs() imports vllm and calls dataclasses.fields(EngineArgs) every time. GenerativeProposer._searchable() calls self._source.knobs() on every propose() call. For a long loop this is harmless (one call per 24h pass), but in tests it adds up. Cache the result with functools.lru_cache on _engine_arg_knobs or memoize in VLLMKnobSource.__init__.

_op_present does a linear scan per call

return any(op in k.name for k in trace.kernels())

trace.kernels() likely filters trace.events on every call. For a large trace called once this is fine, but if autoresearch_v0 is ever called in a tight loop (or _op_present is called per-proposal), pre-build a set of kernel names once.


📊 Reproducibility

StochasticProposer seed is instance-level, not call-level

rng = random.Random(self._seed)

The RNG is re-seeded from self._seed on every propose() call, so successive calls with the same bottleneck_class produce identical outputs. This is fine for single-pass use but means if the loop calls propose() twice in one run (e.g. with different target_op), results are correlated in a non-obvious way. If independent draws are desired across calls, the RNG should be stateful (initialized in __init__, not in propose). Document the current behavior explicitly.

_RULES expected deltas are identical for all proposals

_DELTA_MEAN, _DELTA_LO, _DELTA_HI = 0.05, 0.0, 0.15

All proposals carry the same expected_delta_mean=0.05, so select_interventions/predict_delta ranking is purely a function of the applies_to_kernels coverage, not any prior on proposal quality. This means a no-op knob change and a high-confidence one are indistinguishable to the ranker. The doc is honest about this ("honestly-unproven range"), but it's worth a TODO comment noting that per-knob priors would improve ranking.


💡 Suggestions

_annotation_kind string-matching on str(annotation) is fragile

text = str(annotation).lower()
if "bool" in text:
    return "bool"

str(Optional[int])"typing.optional[int]" — this matches "int" correctly. But str(Union[int, None]) also matches "int". However, str(Optional[bool])"typing.optional[bool]" matches "bool" first — correct. The real problem: str(Optional[int]) contains neither "bool", "int" if some Python version formats it differently (e.g. int | None on 3.10+). Consider typing.get_args recursively instead.

_unenactable closure captures live_restart_fn which may not be defined

def _unenactable(spec: Any) -> str | None:
    if (
        cfg.engine is not None
        and live_restart_fn is None
        and knob_kind(spec.knob) == "structural"
    ):

live_restart_fn and knob_kind must be defined earlier in run_loop. If Phase 4b is extracted or reordered, this silent capture will raise NameError. Pass them explicitly or guard with locals().get("live_restart_fn").

No test for StochasticProposer reproducibility across seeds
The tests cover EngineArgsProposer, FallbackProposer, GenerativeProposer, and TableProposer, but StochasticProposer has no test verifying that the same seed produces the same output, or that different seeds produce different outputs. Given its explicit reproducibility contract, this is a gap.

autoresearch.json serializes spec.value as-is — may not be JSON-serializable

"value": r.spec.value,

InterventionSpec.value is typed int | float | str | bool | object. If a knob value is an enum instance or a numpy scalar (possible from _value_grid with numpy-backed defaults), json.dumps will raise TypeError. Add a default=str fallback or coerce values to JSON-native types before serialization.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce a new autoresearch agent, integrate it into the scheduler loop, and add comprehensive documentation and tests. The design for agentic search, bottleneck classification, and knob generation is well-thought-out, with clear separation of concerns and fallback mechanisms.

One bug was identified in the integration with the scheduler loop.

Bug

File: gitm/scheduler/loop.py

Issue: The _unenactable helper function uses knob_kind(spec.knob) without knob_kind being defined or imported in loop.py. This will result in a NameError when autoresearch runs with a live engine and a structural knob is proposed. The knob_kind function is likely intended to be imported from gitm.kernels.spec.

Proposed change:

--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -11,6 +11,7 @@ from __future__ import annotations
 import json
 import os
 import re
+import time
 import uuid
 from dataclasses import asdict, dataclass
 from pathlib import Path
@@ -24,7 +25,7 @@
     LiveEngineApplicator,
     apply_intervention,
 )
 from gitm.optimizer.attribution import attribute
 from gitm.optimizer.deviation import deviation_summary, deviation_trace, write_deviation_jsonl
 from gitm.optimizer.monitor import (
     AttributionContext,
@@ -32,7 +33,7 @@
     Residuals,
     TraceSummary,
     build_attribution_context,
 )
 from gitm.optimizer.preconditions import GateContext, Preconditions
 from gitm.optimizer.replay import predict_delta
 from gitm.optimizer.report import Claim, Provenance, build_report
@@ -40,6 +41,7 @@
 from gitm.tracer.schema import Trace
 from gitm.tracer.trace import capture_trace
 from gitm.workloads.vllm import VLLMConfig, VLLMRunner
+from gitm.kernels.spec import knob_kind
 
 
 #: The loop's configuration.
@@ -507,6 +509,7 @@
     # Phase 4b - agentic autoresearch through the catalog gate/rollback path.
     if time.time_ns() - started_ns < int(budget_s * 1e9):
         proposer = FallbackProposer(EngineArgsProposer(), TableProposer())
+
         def _unenactable(spec: Any) -> str | None:
             if (
                 cfg.engine is not None

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid zero-default produces non-useful grid for cpu_offload_gb

base = d if isinstance(d, int | float) and d not in (0, False) else 1

cpu_offload_gb has default=0, so base=1 and the grid becomes [1, 2, 4] (GB). The fallback base=1 is undocumented and may produce nonsensical values for other knobs with zero defaults (e.g. a threshold where 1 is still effectively disabled). This should be called out or knob-specific grids should be required.

largest_residual max call fails on empty dict after filtering

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

res.per_kernel is checked for truthiness but the guard is if not res.per_kernel — if per_kernel is a non-empty list of items that all have the same key and that key has mean <= 0, max is called on a non-empty dict and returns correctly, but the early-return logic is sound. However if per_kernel were somehow an empty iterable after the guard (e.g. a custom Residuals subclass with a truthy but empty iterator), max would raise ValueError. Minor, but worth a if not by_op guard after the loop.

autoresearch_v0 applicable field semantics are inverted for rejected items

applicable=applied is not None,

When a spec is rejected by the gate, applied=None so applicable=False. But the loop then does:

if not r.applicable:
    rejected.append(...)
    continue
if r.rolled_back:
    rolled_back.append(...)

This means a spec that was applied but rolled back is added to claims with rolled_back=True. The claim's measured_delta will be negative (the regression value), which gets written to the report as a real measurement. Rolled-back interventions should arguably not appear as claims at all, or the report consumer needs to filter on rolled_back.

StochasticProposer infinite loop risk

max_attempts = max(self._n * 4, len(eligible) * 4)

If n_samples > len(eligible) * len(grid) (more samples requested than unique (knob, value) pairs exist), the loop will exhaust all unique pairs before reaching self._n but keeps attempting, burning all max_attempts iterations on collisions. The loop should break early when len(seen) >= total_unique_pairs.

_engine_arg_knobs uses f.type instead of f.metadata or resolved annotation

kind, choices = _field_kind_and_choices(f.type)

With from __future__ import annotations (which vLLM uses), f.type is a string, not a type object. typing.get_origin on a string returns None, so Literal fields are never detected as enums — the _field_kind_and_choices docstring acknowledges this but the code comment in _engine_arg_knobs does not warn callers that enum introspection is silently broken when forward references are active.


⚡ Performance

_searchable() called once per propose() call but re-introspects EngineArgs every time
VLLMKnobSource.knobs() calls _engine_arg_knobs() which does dataclasses.fields(EngineArgs) on every invocation. In the loop this is called per-class. Cache the result (e.g. functools.lru_cache on _engine_arg_knobs or memoize in VLLMKnobSource).


📊 Reproducibility

StochasticProposer seed is instance-level but not passed through autoresearch
The seed defaults to 0 and is baked into the instance. The autoresearch entry point has no seed parameter, so callers can't control stochastic behavior without constructing the proposer manually. The docs say "reproducible for a given seed, varied by changing it" but the loop never varies it — every run uses seed 0 via FallbackProposer(EngineArgsProposer(), TableProposer()) which doesn't use StochasticProposer at all. This is correctly noted as "not wired in yet" but worth flagging: when it is wired in, the seed should come from LoopConfig.


💡 Suggestions

autoresearch.json is written unconditionally even when budget was exhausted
The ar_run is set to an empty AutoresearchRun when budget is exceeded, and the JSON write happens regardless. This is fine but could mislead: the file will exist with results: [] and no indication that the phase was skipped vs. ran and found nothing. A "skipped": true field or absence of the file would be clearer.

_unenactable in loop.py references knob_kind which is not imported in the diff

and knob_kind(spec.knob) == "structural"

knob_kind is used but not shown being imported in this diff. If it's not already in scope from an existing import, this is a NameError at runtime.

classify_bottleneck tie-breaking favors idle_stall

return IDLE_STALL if sc_score >= mem_score else MEMORY_BOUND

Exact tie (sc_score == mem_score >= 1.0) always resolves to idle_stall. This is a minor determinism concern — document the tie-break intention explicitly since both thresholds being hit equally is a realistic edge case (e.g. heavy memcpy with fully serialized kernels).

No test for StochasticProposer sampling distribution or deduplication
The tests cover GenerativeProposer and EngineArgsProposer thoroughly but StochasticProposer has no test verifying that: (a) the seed produces deterministic output, (b) duplicate (knob, value) pairs are not emitted, (c) epsilon=0 yields only affine knobs.

_candidate_spec hardcodes workload="vllm-decode" default in propose()
The static propose() function hardcodes workload="vllm-decode" inside _candidate_spec, which means TableProposer always emits vLLM-specific applicability even when used as a fallback for a non-vLLM FallbackProposer. The workload label should be a parameter or the TableProposer should accept a workload argument.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid with zero default produces values excluding the default incorrectly
For cpu_offload_gb with default=0, base is set to 1 (because d == 0), so the grid is [0.5→1 rounded, 2, 4] = [1, 2, 4]. The v != d filter then drops nothing since d=0. This is intentional per the comment but the half-multiplier (0.5 × 1 = 0.5 → rounds to 1) silently collapses — you get [1, 2, 4] instead of [1, 2, 4] (no harm here, but int(round(0.5)) is 0 in Python's banker's rounding, so 0 gets dropped by the >= 1 guard, meaning only [2, 4] survive). Worth a unit test specifically for default=0.

largest_residual uses max() without a guard on empty by_op
If res.per_kernel is non-empty but all entries have the same op and r_kt <= 0, the early-return check if mean <= 0 handles it. However if res.per_kernel is non-empty and by_op is somehow empty (impossible with the current loop, but fragile), max() raises. Low risk but worth an if not by_op guard.

autoresearch_v0 calls _op_present but target may have been computed from stale residuals
target.op is validated against the current trace via _op_present, but the aimed_at variable (used for target_op in results) is set unconditionally to target.op regardless of whether _op_present returned True. So r.target_op will be "paged_attention" even when the op isn't in the trace, which is intentional per the docs but contradicts the test test_off_trace_residual_op_is_not_tagged asserting r.target_op == "paged_attention" — actually that test passes because of this behaviour, but the field name target_op implies "what we scoped to," which is misleading when applies_to_kernels == [].

_argparse_domains: nargs=1 treated as list

is_list = nargs in ("+", "*") or (isinstance(nargs, int) and nargs > 1)

nargs=1 correctly yields a single-element list in argparse (not a scalar), so a field with nargs=1 will be treated as a scalar knob when it isn't. Should be nargs >= 1 or nargs != None.

_knobs_from_engine_args uses f.type (string under from __future__ import annotations)

kind, choices = _knob_domain(f.type, domain)

With PEP 563 deferred evaluation, f.type is a string like "Optional[int]", not a type object. _field_kind_and_choices does str(annotation).lower() so it works for the coarse match, but typing.get_origin/get_args will always fail silently, meaning Literal[...] fields never get proper enum treatment from the annotation path. This is acknowledged in the docs but not guarded — get_type_hints() would resolve it.


⚡ Performance

_op_present is O(n) per call, called once per autoresearch invocation
Fine at current scale, but worth caching as a set if this gets called in a tight loop with large traces.

StochasticProposer: rng.choices(eligible, weights=weights, k=1)[0] in a loop
Calling choices with k=1 in a max_attempts loop re-normalises weights each iteration. Preferable to draw k=max_attempts pairs upfront and iterate, or use random.choices with k=n_samples directly if deduplication isn't critical.


📊 Reproducibility

StochasticProposer seed is per-instance, not per-call
The same StochasticProposer instance always produces the same sequence, which is good. But the default seed=0 means two independently constructed proposers with no explicit seed produce identical output — this is probably fine for a search tool but could mask diversity bugs in tests.

EngineArgsProposer candidate order depends on dataclasses.fields() order
Field order is deterministic within a Python version but may change across vLLM versions, making the out[:self._max] cap version-sensitive. Worth sorting by name before slicing.


💡 Suggestions

autoresearch_v0 has no timeout or per-proposal time budget
If apply_intervention is slow (live engine, real measurement), a large candidate set will block the loop. The budget check happens before Phase 4b but not within it.

FallbackProposer falls back silently on empty primary
If EngineArgsProposer returns empty because all generated knobs are in the catalog (plausible after catalog grows), TableProposer silently takes over. A debug-level log would help diagnose unexpected fallback in production.

autoresearch.json is overwritten on every run with no rotation
Concurrent runs or a crash mid-write could leave a partial file. Consider writing to a temp file and rename() atomically.

_RULES rationale for long_prefill_token_threshold=2048 says "lower the long-prefill threshold" but the value is 2048 and the default is 0
Setting a previously-unset threshold to 2048 raises the bar for what counts as "long," not lowers it. The rationale comment is inverted.

Test file imports private symbols (_CLASS_KEYWORDS, _argparse_domains, etc.) at module level after a gap
The second import block at line ~340 of the test file uses # noqa: E402 but is not inside a fixture or guard — if any of these private names are renamed, the entire test module fails to import. Grouping all imports at the top or using importlib for private-symbol tests would be more resilient.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce a new autoresearch agent, integrating it into the existing optimization loop. The design is robust, with clear separation of concerns, comprehensive testing, and good documentation.

Here are some minor improvements and observations:

gitm/agents/autoresearch.py

  1. Improve _value_grid base calculation for numeric types
    The calculation of base for int and float knobs can be slightly simplified and made more explicit about handling non-numeric defaults. Currently, d is knob.default, which could be None or a string. Making d_val explicitly numeric before checking for zero improves clarity.

    --- a/gitm/agents/autoresearch.py
    +++ b/gitm/agents/autoresearch.py
    @@ -297,8 +297,8 @@
         if knob.kind == "bool":
             return [not bool(knob.default)]
         if knob.kind == "enum":
             return [c for c in knob.choices if c != knob.default]
         if knob.kind in ("int", "float"):
    -        d = knob.default
    -        base = d if isinstance(d, int | float) and d not in (0, False) else 1
    +        d_val = knob.default if isinstance(knob.default, (int, float)) else 0
    +        base = d_val if d_val != 0 else 1
             raw = [base * m for m in _GRID_MULTIPLIERS]
             if knob.kind == "int":
                 vals = sorted({int(round(x)) for x in raw if round(x) >= 1})

gitm/scheduler/loop.py

  1. Explicitly import knob_kind
    The _unenactable helper function uses knob_kind without an explicit import in loop.py. While it might be implicitly available through other imports, it's best practice to explicitly import all directly used functions for clarity and maintainability.

    --- a/gitm/scheduler/loop.py
    +++ b/gitm/scheduler/loop.py
    @@ -20,6 +20,7 @@
     from gitm.agents.autoresearch import (
         AutoresearchRun,
         EngineArgsProposer,
         FallbackProposer,
         TableProposer,
         autoresearch,
         classify_bottleneck,
     )
     from gitm.agents.policy import Policy, select_interventions
     from gitm.kernels.library import load_library
    +from gitm.kernels.spec import knob_kind
     from gitm.optimizer.apply import (
         Applicator,
         DryRunApplicator,
         LiveEngineApplicator,
         apply_intervention,
     )

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce an "autoresearch" agent that proposes non-catalog vLLM optimization candidates. This agent classifies bottlenecks, targets the largest residual operations, and generates intervention proposals from the vLLM EngineArgs surface or a fallback table. These proposals are then routed through the existing safety gate and rollback mechanism. The feature is integrated into the main scheduler loop as Phase 4b and includes comprehensive documentation and tests.

Overall, the design is robust, with clear separation of concerns, extensibility through Proposer and KnobSource seams, and strong safety measures (moderate tier, rollback gate). The documentation is thorough and transparent about the current limitations and future roadmap.

Issues and Suggestions

Bug

File: gitm/scheduler/loop.py

The _unenactable function uses knob_kind(spec.knob) without knob_kind being imported or defined in loop.py. This will result in a NameError.

--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -19,6 +19,7 @@ from gitm.agents.autoresearch import (
     autoresearch,
     classify_bottleneck,
 )
+from gitm.optimizer.apply import knob_kind
 from gitm.agents.policy import Policy, select_interventions
 from gitm.kernels.library import load_library
 from gitm.optimizer.apply import (

Suggested change:

Add the following import statement to gitm/scheduler/loop.py:

from gitm.optimizer.apply import knob_kind

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

max on empty iterator in largest_residual

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

If res.per_kernel is non-empty but all entries share the same op, this works — but the guard if not res.per_kernel only protects the empty-list case. More critically, if by_op somehow ends up empty after the loop (shouldn't happen given the guard, but defensive code is cheap), max() raises ValueError. Low risk, but worth a comment or explicit guard.

f.type vs dataclasses.fields annotation

kind, choices = _knob_domain(f.type, domain)

In Python ≥ 3.10 with from __future__ import annotations, f.type is a string, not the actual type object. _field_kind_and_choices tries typing.get_origin on a string, which silently falls back to _annotation_kind doing str(annotation).lower() — string matching on a stringified annotation. This is documented as "best-effort" but can misclassify: a field annotated Optional[int] becomes "optional[int]" which contains "int" and works, but Union[int, None] becomes "int | none" — also fine. However Optional[float] contains both "float" and (in some repr) nothing surprising. The real risk is Literal["recompute", "swap"] as a string: typing.get_origin on a string returns None, so it falls through to _annotation_kind returning "str" and choices=(), silently losing the enum domain. The argparse path rescues this for vLLM, but it's a silent correctness hole for other KnobSource implementations.

applicable field semantics are misleading

applicable=applied is not None,

applicable=True means "not gate-rejected", but a rolled-back result also has applicable=True. In loop.py the loop checks if not r.applicable: rejected.append(...) and then separately checks r.rolled_back. A rolled-back result gets added to claims with rolled_back=True — that's intentional — but the field name applicable meaning "passed the gate" (not "the change stuck") is a semantic trap for future callers.

Off-by-one in StochasticProposer termination

max_attempts = max(self._n * 4, len(eligible) * 4)

If eligible is large but n_samples is small, max_attempts grows proportionally to the surface size. With a high collision rate this can run many redundant iterations. Not a correctness bug, but max_attempts should be capped or the sampling strategy changed (sample without replacement from the full (knob, value) product).

weights check is wrong for the all-epsilon=0 case

if not any(weights):

any([0.0, 0.0, ...]) is False, so this correctly short-circuits when epsilon=0 and nothing is affine. But if epsilon is a very small positive float (e.g. 1e-300), any(weights) is True and rng.choices with near-zero weights will technically work, but the intent comment says "no heuristic signal and no entropy" — the condition should probably be if not any(w > 0 for w in weights) for clarity, though in practice the current code is fine for float epsilon > 0.

ar_run referenced before assignment in loop.py

if time.time_ns() - started_ns < int(budget_s * 1e9):
    ...
    ar_run = autoresearch(...)
else:
    ar_run = AutoresearchRun(bottleneck_class=classify_bottleneck(trace), results=[])

classify_bottleneck(trace) is called a second time in the else branch. If trace is not defined at this point in the loop (e.g. an earlier phase raised and was caught, leaving trace unbound), both branches can fail. More importantly, ar_run.bottleneck_class and ar_run.results are used unconditionally after the block — but if the else branch raises (e.g. trace unbound), the summary dict at the bottom also fails. The else branch should be AutoresearchRun(bottleneck_class="compute_bound", results=[]) to be safe and not re-run classification.


🔒 Security

_unenactable captures live_restart_fn from the outer scope

def _unenactable(spec: Any) -> str | None:
    if (
        cfg.engine is not None
        and live_restart_fn is None
        and knob_kind(spec.knob) == "structural"
    ):

spec.knob comes from an InterventionSpec generated by introspecting EngineArgs. If a KnobSource were ever supplied externally (not the case today, but the seam is open), a crafted knob name could influence knob_kind dispatch. Low risk in current code; worth noting as the seam widens.


⚡ Performance

_engine_arg_knobs / _argparse_domains called once per EngineArgsProposer instantiation — the add_cli_args call constructs a full ArgumentParser on every instantiation. Since EngineArgsProposer is instantiated once per loop iteration in loop.py, this is fine today, but if the proposer is reinstantiated frequently (e.g. in tests), this becomes expensive. Consider caching with functools.lru_cache on _engine_arg_knobs.

_searchable() recomputes every propose() call

def _searchable(self) -> list[Knob]:
    return [k for k in self._source.knobs() if k.name not in self._catalog and _value_grid(k)]

_value_grid is called for every knob on every propose() call. Since _ProposerBase is frozen after __init__, cache this result.


📊 Reproducibility

StochasticProposer seed is an instance attribute, not a global
The seed is per-instance and defaults to 0. The loop currently doesn't use StochasticProposer, but when it does, callers must explicitly vary the seed across runs to get different proposals. There's no mechanism to automatically advance the seed between loop iterations — two consecutive 24-hour runs with the same config will propose the exact same stochastic candidates. Consider seeding from run_id or a counter.

classify_bottleneck is deterministic but depends on _serialized_fraction from gitm.optimizer.monitor — that function's definition isn't visible in this diff. If it has any non-deterministic component (e.g. sorting with equal keys), classification could vary across runs on identical traces.


💡 Suggestions

The autoresearch.json write has no error handling

(run_dir / "autoresearch.json").write_text(json.dumps(...))

r.spec.value can be any object (including non-JSON-serializable types like custom enums or dataclass instances). A TypeError here would crash the entire loop after all interventions have already been applied. Wrap in try/except or ensure value is always a JSON primitive at spec construction time.

_value_grid with default=0 silently substitutes base=1

base = d if isinstance(d, int | float) and d not in (0, False) else 1

For a knob like cpu_offload_gb with default=0, the grid becomes [0 (skipped), 2, 4] — reasonable. But compilation_config with default=0 gets [1 (skipped since == default? No — 1 != 0), 2, 4]. The explicit grid=(2, 3) in _FALLBACK_KNOBS patches this, but without the explicit grid, ½×1=0 is filtered by round(x) >= 1, and the grid is [1, 2, 4] — includes 1, which may be a no-op for torch.compile. The explicit grid override is the right fix; it should be documented as required for zero-default numeric knobs.

test_engineargs_proposer_scopes_candidates_to_bottleneck_class asserts block_size in mem

assert mem == {"cpu_offload_gb", "block_size"}  # both memory-affine by name

This test asserts that block_size (a catalog knob) appears in the generated set when catalog_knobs=set(). But this is testing an artifact of the empty catalog override, not realistic behavior. The test should either use catalog_knobs={"block_size"} (realistic) or be renamed to document the intentional override.

No timeout / budget enforcement inside autoresearch_v0
Each apply_intervention call measures the live engine. If len(proposals) is large (up to 24 from EngineArgsProposer) and each measurement takes seconds, phase 4b can significantly overrun the remaining budget. The budget check is only at phase entry; there's no mid-phase check against budget_s.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

### gitm/agents/autoresearch.py
No issues found.

### gitm/scheduler/loop.py
```diff
--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -19,6 +19,7 @@
     DryRunApplicator,
     LiveEngineApplicator,
     apply_intervention,
+    knob_kind,
 )
 from gitm.optimizer.attribution import attribute
 from gitm.optimizer.deviation import deviation_summary, deviation_trace, write_deviation_jsonl

Issue: The knob_kind function is used in the _unenactable helper function but is not imported, which will lead to a NameError.
Improvement: Import knob_kind from gitm.optimizer.apply.

gitm/tracer/vllm_stats.py

--- a/gitm/tracer/vllm_stats.py
+++ b/gitm/tracer/vllm_stats.py
@@ -83,66 +83,27 @@ def _first_attr(obj: Any, *paths: str) -> Any:
 def _len_or_none(x: Any) -> int | None:
     try:
         return len(x)
     except TypeError:
         return None
 
 
 def _schedulers(engine: Any) -> list[Any]:
     """Resolve the engine's scheduler(s) as a list (vLLM may keep one per PP stage)."""
     sched = _first_attr(
-        engine,
-        # vLLM V0.
-        "scheduler",
-        "engine.scheduler",
-        "llm_engine.scheduler",
-        # vLLM V1 (in-process, VLLM_ENABLE_V1_MULTIPROCESSING=0): the scheduler
-        # lives inside the EngineCore, not on the LLMEngine. Paths vary across
-        # v0.2x point releases — GPU-validate the exact one on the target build.
-        "llm_engine.engine_core.engine_core.scheduler",
-        "llm_engine.engine_core.scheduler",
-        "engine_core.engine_core.scheduler",
-        "engine_core.scheduler",
+        engine, "scheduler", "engine.scheduler", "llm_engine.scheduler"
     )
     if sched is None:
         return []
     return list(sched) if isinstance(sched, list | tuple) else [sched]
 
 
-def _v1_scheduler_stats(scheduler: Any) -> dict[str, Any]:
-    """vLLM V1 stats via ``scheduler.make_stats()`` — V1 doesn't keep the V0
-    running/waiting deques in the same shape, but exposes a stats object with
-    ``num_running_reqs`` / ``num_waiting_reqs`` / ``kv_cache_usage``. Best-effort;
-    returns only the fields it actually found. GPU-validate the attr names on the
-    target vLLM build (they've shifted across v0.2x).
-    """
-    make = getattr(scheduler, "make_stats", None)
-    if not callable(make):
-        return {}
-    try:
-        stats = make()
-    except Exception:
-        return {}
-    if stats is None:
-        return {}
-    out: dict[str, Any] = {}
-    for field_name, attr in (("num_running", "num_running_reqs"),
-                             ("num_waiting", "num_waiting_reqs")):
-        v = getattr(stats, attr, None)
-        if isinstance(v, int):
-            out[field_name] = v
-    ku = getattr(stats, "kv_cache_usage", None)
-    if isinstance(ku, int | float):
-        out["gpu_cache_usage"] = float(ku)
-    return out
-
-
 def _max_num_seqs(engine: Any) -> int | None:
     val = _first_attr(
         engine,
         "scheduler_config.max_num_seqs",
         "engine.scheduler_config.max_num_seqs",
         "llm_engine.scheduler_config.max_num_seqs",
         "vllm_config.scheduler_config.max_num_seqs",
     )
     return int(val) if isinstance(val, int) and val > 0 else None
 
@@ -174,16 +127,6 @@ def read_scheduler_stats(engine: Any, *, t_ns: int = 0) -> SchedulerSample | Non
         if usage is not None:
             sample.gpu_cache_usage = usage
             saw_any = True
 
-        # vLLM V1: fill running / waiting / cache from the scheduler's stat object
-        # where the VO deques weren't exposed (they read empty on V1)
-        for sch in schedulers:
-            for field_name, val in _v1_scheduler_stats(sch).items():
-                if getattr(sample, field_name) is None:
-                    setattr(sample, field_name, val)
-                    saw_any = True
-
     # Total unfinished — a stable public method on LLMEngine across versions.
     getter = _first_attr(
         engine,
         "get_num_unfinished_requests",
         "engine.get_num_unfinished_requests",
         "llm_engine.get_num_unfinished_requests",
     )

Issue: The removal of _v1_scheduler_stats and the simplification of _schedulers paths might lead to a regression in collecting scheduler statistics for vLLM V1 versions if they do not expose the necessary attributes directly on the scheduler object or through the simplified paths. The commit message does not explicitly state that vLLM V1 support for stats collection is being dropped or refactored.
Improvement: If vLLM V1 is still a target for scheduler statistics collection, ensure that the current read_scheduler_stats logic correctly extracts all required metrics from V1 engines. If V1 support is intentionally dropped, this should be explicitly stated in the documentation or commit message.

gitm/workloads.py

No issues found.

tests/test_autoresearch.py

No issues found.

tests/test_vllm_stats_v1.py

--- a/tests/test_vllm_stats_v1.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Read scheduler stats from a vLLM V1-shaped engine.
-
-V1 keeps the scheduler inside EngineCore and exposes a stats object via
-make_stats() (num_running_reqs / num_waiting_reqs / kv_cache_usage) instead of
-the V0 running/waiting deques. Fake that shape to pin the read path off-GPU; the
-exact attr names still need GPU validation on the target vLLM build.
-"""
-
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-from gitm.tracer.vllm_stats import read_scheduler_stats
-
-
-def _v1_engine(running: int, waiting: int, cache: float, max_seqs: int = 256):
-    stats = SimpleNamespace(
-        num_running_reqs=running, num_waiting_reqs=waiting, kv_cache_usage=cache
-    )
-    scheduler = SimpleNamespace(make_stats=lambda: stats)
-    # llm.llm_engine.engine_core.engine_core.scheduler (in-process V1)
-    return SimpleNamespace(
-        llm_engine=SimpleNamespace(
-            engine_core=SimpleNamespace(engine_core=SimpleNamespace(scheduler=scheduler))
-        ),
-        scheduler_config=SimpleNamespace(max_num_seqs=max_seqs),
-    )
-
-
-def test_reads_v1_stats_object():
-    s = read_scheduler_stats(_v1_engine(running=3, waiting=12, cache=0.87), t_ns=0)
-    assert s is not None
-    assert s.num_running == 3
-    assert s.num_waiting == 12
-    assert s.gpu_cache_usage == 0.87
-    assert s.batch_occupancy == 3 / 256  # occupancy derived from V1 num_running
-
-
-def test_none_engine_still_none():
-    assert read_scheduler_stats(None) is None
-
-
-def test_v1_engine_without_stats_is_none():
-    # a V1-shaped engine whose scheduler exposes nothing readable -> None, no crash.
-    empty = SimpleNamespace(
-        llm_engine=SimpleNamespace(
-            engine_core=SimpleNamespace(engine_core=SimpleNamespace(scheduler=object()))
-        )
-    )
-    assert read_scheduler_stats(empty) is None

Issue: The removal of test_vllm_stats_v1.py indicates that vLLM V1-specific scheduler stats collection is no longer tested. This aligns with the removal of _v1_scheduler_stats in gitm/tracer/vllm_stats.py. If vLLM V1 is still a supported target, this removal could lead to undetected regressions in stats collection for that version.
Improvement: If vLLM V1 is still supported, either reintroduce tests for its stats collection (adapting to any new introspection methods) or explicitly document that V1-specific stats collection is no longer supported.

tests/test_workload_bootstrap.py

No issues found.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid zero-default ladder produces no useful values for cpu_offload_gb
cpu_offload_gb has default=0. The code special-cases d not in (0, False) by substituting base=1, so it searches [0.5→1, 2, 4] rounded to ints → [2, 4]. That's fine for this knob, but the comment says "½×/2×/4× of the default" — the actual behaviour for zero-defaults is the substituted base, which is silently inconsistent with the documented contract. More critically, int(round(0.5 * 1)) = 1, which equals base and is then filtered by v != d (d=0), so it survives — but ½× of a default-0 knob is meaningless noise. The substitution logic deserves a comment or a dedicated branch.

_vllm_decode_factory missing return / blank line before function
In gitm/workloads.py the diff shows:

    run.engine = llm
    llm.gitm_throughput_fn = _throughput
    llm.gitm_restart_fn = _restart
    return run

def _vllm_synthetic_runner(...):

There's no blank line between the return and the new top-level function. This is a parse-level style issue that some linters treat as an error, but more importantly — if the surrounding context (not shown) had a previously missing return run, this diff may be fixing a latent bug where _vllm_decode_factory returned None. Worth confirming the function actually completed before this diff.

StochasticProposer: any(weights) is always True when epsilon > 0

weights = [1.0 if _affine(...) else self._epsilon for k, _grid in eligible]
if not any(weights):
    return []

When epsilon > 0 (default 0.15), every weight is positive, so any(weights) is always True and the guard never fires. The only case it fires is epsilon=0 with zero affine knobs — but then rng.choices gets all-zero weights and raises ValueError. The guard should be if not any(w > 0 for w in weights) or equivalently if not eligible.

largest_residual calls max() before checking all-negative means

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))
mean = sum(values) / len(values)
if mean <= 0:
    return None

max() is called unconditionally, then the result is discarded if the winner is ≤ 0. This is correct but wasteful — more importantly, if by_op is empty (all per_kernel entries have no op), max() raises ValueError. The if not res.per_kernel guard above won't catch this if op fields are empty strings.

autoresearch_v0: target_op recorded on results even when trace-absent
aimed_at = target.op if target is not None else None — this is set before the _op_present check, so r.target_op is always target.op regardless of whether the op was found in the trace. The doc says "recorded as the justification", so this is intentional per the docs — but target_op and spec.applies_to_kernels will diverge silently, which could confuse consumers of autoresearch.json.


🔒 Security

f.type used directly from dataclass field

kind, choices = _knob_domain(f.type, domain)

With from __future__ import annotations, f.type is a string (the annotation as written), not a resolved type. Passing an arbitrary string from a third-party package (vllm.EngineArgs) into _annotation_kind and _field_kind_and_choices is low-risk here (just string matching), but typing.get_origin / typing.get_args on a raw string will silently return None rather than raising — the try/except Exception swallows this, making it hard to debug misclassified fields. Use typing.get_type_hints() to resolve annotations, or document explicitly that string annotations fall through to the coarse path.


⚡ Performance

_engine_arg_knobs() / _argparse_domains() called per propose() invocation
VLLMKnobSource.knobs() calls _engine_arg_knobs() on every call, which calls _argparse_domains(), which constructs a full ArgumentParser from EngineArgs.add_cli_args. This is expensive (~hundreds of argparse actions). Since VLLMKnobSource is stateless, the result should be cached — either with functools.lru_cache on _engine_arg_knobs or by memoizing in VLLMKnobSource.__init__.

_searchable() called on every propose() call in _ProposerBase
_searchable() reloads the full knob list and filters against the catalog set each time. Since proposers are reused across loop iterations, caching _searchable() (e.g. functools.cached_property) would avoid redundant work.


📊 Reproducibility

StochasticProposer seed is instance-level, not call-level
The RNG is re-seeded to self._seed on every propose() call:

rng = random.Random(self._seed)

This means repeated calls with the same bottleneck_class return identical candidates — a loop running Phase 4b twice in the same session would propose the same things both times. If diversity across invocations is desired, the seed should be combined with a call counter or timestamp. The docs acknowledge this ("reproducible for a given seed, varied by changing it") but the loop has no mechanism to vary it.

_DELTA_MEAN, _DELTA_LO, _DELTA_HI are module-level constants, not versioned
If these are ever tuned, there's no version tag in the spec's source field connecting the delta band to the version that produced it. Historical autoresearch.json files become uninterpretable when the constants change.


💡 Suggestions

FallbackProposer silently swallows exceptions from primary

specs = self._primary.propose(bottleneck_class, target_op=target_op)
return specs or self._secondary.propose(...)

If primary.propose() raises (e.g. EngineArgs introspection fails mid-run), the exception propagates rather than falling back to secondary. Consider wrapping with a try/except Exception and logging the failure before falling back — consistent with the "always produce something" intent.

autoresearch.json written unconditionally even on budget-skip
When the budget is exhausted before Phase 4b, ar_run = AutoresearchRun(bottleneck_class=..., results=[]) — but the JSON is still written (with an empty results list). This is harmless but means the file doesn't distinguish "skipped due to budget" from "ran and found nothing". A "skipped": true key or writing null would help downstream tooling.

test_engineargs_proposer_scopes_candidates_to_bottleneck_class asserts block_size is memory-affine

assert mem == {"cpu_offload_gb", "block_size"}  # both memory-affine by name

block_size contains "block", which matches the "block" keyword in _CLASS_KEYWORDS["memory_bound"]. This test will break if the keyword list is tightened (e.g. changed to "kv_block"). The test is testing the keyword heuristic incidentally — it should either explicitly test the heuristic or use a knob whose affinity is unambiguous.

_knobs_from_engine_args uses f.type instead of f.type via get_type_hints
Under from __future__ import annotations (which vLLM uses), dataclasses.fields(EngineArgs)[i].type is always a string. _knob_domain(f.type, domain) will always fall through to _annotation_kind(str_annotation), making the _field_kind_and_choices Literal branch dead code for live vLLM. Add a typing.get_type_hints() call (with include_extras=True) and pass the resolved annotation, or document the limitation explicitly.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -100,7 +100,7 @@
     """True if ``op`` names (a substring of) a real kernel in the trace.
 
     Guards the ``applies_to_kernels`` tagging: the residual op label comes from
     the predicted graph, so only tag a proposal with it when it actually matches
     a captured kernel name — otherwise ``predict_delta`` coverage would be 0 and
     the proposal would rank as worthless rather than untargeted.
     """
-    return any(op in k.name for k in trace.kernels())
+    return any(op in k.name for k in trace.kernels()) if op else False
 
 
 # --- candidate table --------------------------------------------------------

Reasoning for the change:
The _op_present function is called with target.op which can be None if target is None. If op is None, the expression op in k.name would raise a TypeError because None is not a string. Adding if op else False ensures that if op is None or an empty string, it correctly returns False without error.


--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -17,6 +17,7 @@
     autoresearch,
     classify_bottleneck,
 )
 from gitm.agents.policy import Policy, select_interventions
 from gitm.kernels.library import load_library
 from gitm.optimizer.apply import (
     Applicator,
     DryRunApplicator,
     LiveEngineApplicator,
     apply_intervention,
 )
 from gitm.optimizer.attribution import attribute
 from gitm.optimizer.deviation import deviation_summary, deviation_trace, write_deviation_jsonl
+from gitm.kernels.spec import knob_kind
 from gitm.optimizer.monitor import (
     Residuals,
     SchedulerSample,

Reasoning for the change:
The _unenactable function defined within run_loop uses knob_kind(spec.knob). However, knob_kind is not defined in loop.py nor imported. It needs to be imported from gitm.kernels.spec to resolve the NameError.


--- a/gitm/tracer/vllm_stats.py
+++ b/gitm/tracer/vllm_stats.py
@@ -83,20 +83,10 @@
 def _len_or_none(x: Any) -> int | None:
     try:
         return len(x)
     except TypeError:
         return None
 
 
-def _schedulers(engine: Any) -> list[Any]:
-    """Resolve the engine's scheduler(s) as a list (vLLM may keep one per PP stage)."""
-    sched = _first_attr(
-        engine, "scheduler", "engine.scheduler", "llm_engine.scheduler"
-    )
-    if sched is None:
-        return []
-    return list(sched) if isinstance(sched, list | tuple) else [sched]
-
-
 def _max_num_seqs(engine: Any) -> int | None:
     val = _first_attr(
         engine,
         "scheduler_config.max_num_seqs",
         "engine.scheduler_config.max_num_seqs",
         "llm_engine.scheduler_config.max_num_seqs",
         "vllm_config.scheduler_config.max_num_seqs",
     )

Reasoning for the change:
The _schedulers function was removed in a previous commit (or is intended to be removed) as indicated by the deletion of test_vllm_stats_v1.py and the removal of _v1_scheduler_stats in this diff. However, the _schedulers function itself was not fully removed, only its V1-specific paths were commented out. The function is still present but unused in the provided diff. Removing the entire function if it's no longer needed simplifies the code. If it is still needed, the commented-out V1 paths should be removed. Assuming it's no longer needed for the current read_scheduler_stats logic, it should be removed.


--- a/gitm/tracer/vllm_stats.py
+++ b/gitm/tracer/vllm_stats.py
@@ -135,6 +125,19 @@
     """
     sample = SchedulerSample(t_ns=t_ns)
     saw_any = False
+
+    def _schedulers(engine: Any) -> list[Any]:
+        """Resolve the engine's scheduler(s) as a list (vLLM may keep one per PP stage)."""
+        sched = _first_attr(
+            engine,
+            "scheduler",
+            "engine.scheduler",
+            "llm_engine.scheduler",
+        )
+        if sched is None:
+            return []
+        return list(sched) if isinstance(sched, list | tuple) else [sched]
+
     schedulers = _schedulers(engine)
     if schedulers:
         totals: dict[str, int] = {}

Reasoning for the change:
The _schedulers function was moved from the module level to inside read_scheduler_stats. This is a good refactoring as _schedulers is only used by read_scheduler_stats and keeps the module namespace cleaner.


--- a/gitm/workloads.py
+++ b/gitm/workloads.py
@@ -632,6 +643,9 @@
                 f"restart candidate for {knob!r} failed to build — likely a "
                 f"two-engine V1 distributed clash: {exc}"
             ) from exc
 
+    # Dynamically attach gitm-specific functions to the LLM object.
+    # This is generally discouraged; consider a wrapper class or explicit passing.
     run.engine = llm
     llm.gitm_throughput_fn = _throughput
     llm.gitm_restart_fn = _restart
     return run

Reasoning for the change:
Adding a comment to highlight that dynamically attaching attributes to an object (llm.gitm_throughput_fn = ...) is generally not a best practice. This provides a hint for future refactoring towards a cleaner design (e.g., using a wrapper class or passing these functions explicitly).

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Code Review

🐛 Bugs

_vllm_decode_factory returns inside a nested scope without a prior return path
The diff adds run.engine = llm; llm.gitm_throughput_fn = ...; return run but the hunk ends with an incomplete function — there's no return run visible before the new code, and the _vllm_synthetic_runner definition follows immediately without a blank line / guard. If the original function had multiple return paths (e.g. early returns on error), those paths now fall through without setting engine/restart attrs. Verify the full function still has exactly one exit.

ar_run referenced before assignment in summary dict

# loop.py ~L659
"bottleneck_class": ar_run.bottleneck_class,

If the budget check at the top of Phase 4b is somehow bypassed (e.g. an exception in the if block before the else), ar_run is unbound. The else branch assigns a safe default, but an exception mid-block leaves it unbound. Wrap Phase 4b in try/except and ensure ar_run is always initialised before the block.

_value_grid with default=0 uses base=1 silently

base = d if isinstance(d, int | float) and d not in (0, False) else 1

For cpu_offload_gb (default=0), the grid becomes [0, 2, 4] (after rounding), but 0 equals the default and is filtered, leaving [2, 4]. This is probably fine, but base=1 is an undocumented magic number — a comment or named constant would help, and the test for int grids only checks default=1, missing the default=0 case.

test_engineargs_proposer_scopes_candidates_to_bottleneck_class assertion is wrong

assert mem == {"cpu_offload_gb", "block_size"}  # 

The test passes catalog_knobs=set() so block_size is not excluded, but the preceding test (test_engineargs_proposer_excludes_catalog_knobs) explicitly asserts it is excluded when in the catalog. The test's own comment is truncated (trailing # ). This is likely a copy-paste mistake — the assertion should probably be {"cpu_offload_gb"} when the catalog exclusion test is already separate.

largest_residual uses max() over a mutable dict.items() view with a lambda that recomputes

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

Functionally correct, but the mean is computed twice (once in max, once to check if mean <= 0). Minor, but could diverge if by_op were ever mutated between calls. Pre-compute means into a separate dict.


🔒 Security

_argparse_domains executes arbitrary class methods from an imported module

parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())

If EngineArgs is monkey-patched or comes from a compromised environment, this calls arbitrary code. The except Exception: return {} swallows the failure silently — log a warning at minimum so supply-chain issues aren't invisible.


⚡ Performance

_op_present is O(n) per call, called once per autoresearch_v0 invocation
Fine for current use, but if called in a tight loop (future batching), precompute a set of kernel name substrings once from the trace.

StochasticProposer calls _searchable() on every propose() invocation
_searchable() calls self._source.knobs() and load_library() (via self._catalog) each time. VLLMKnobSource.knobs() re-runs _engine_arg_knobs() → may re-import vLLM. Cache the result, at least within a single run.


📊 Reproducibility

StochasticProposer seed is an instance attribute, not reset per propose() call

rng = random.Random(self._seed)

rng is re-created fresh each call, so repeated calls with the same bottleneck_class always return identical samples. This means if the proposer is called twice (e.g. retry logic), it won't explore new candidates. This may be intentional, but it should be documented — or the seed should advance per call.

EngineArgsProposer default max_candidates=24 creates order-dependent truncation
The order of dataclasses.fields() is CPython-implementation-defined in theory (stable in practice). Combined with the out[:self._max] slice, the 24-candidate cap is sensitive to field declaration order in EngineArgs across vLLM versions. A shuffle (with a fixed seed) before truncation would make this reproducible across versions.


💡 Suggestions

No error handling if autoresearch.json write fails

(run_dir / "autoresearch.json").write_text(...)

A disk-full or permissions error here will abort the loop before Phase 5. Wrap in try/except and log a warning — the JSON is observability, not correctness.

_NON_TUNABLE_HINTS substring matching is over-broad
"name" as a hint will match max_num_seqs → no, but will match engine_name, model_name, etc. Consider requiring word-boundary matching or switching to a whitelist of known non-tunable fields, since the EngineArgs surface is stable and finite.

FallbackProposer silently falls back on empty list, not on error
If EngineArgsProposer.propose() raises (e.g. vLLM internal error), the exception propagates rather than falling back to TableProposer. Given the belt-and-suspenders design intent, a try/except with fallback would be safer here.

autoresearch_v0 reject callback result is not validated
The callback returns str | None, but there's no guard against it returning an empty string "", which is falsy but not None. A caller returning "" to mean "no rejection" would be silently treated as accepted. Normalize with reason = reason or None.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

## gitm/agents/autoresearch.py

No issues found.

## gitm/scheduler/loop.py

### Bug: Missing import for `knob_kind`

The function `_unenactable` uses `knob_kind(spec.knob)` but `knob_kind` is not defined or imported in this file. This will lead to a `NameError` at runtime. `knob_kind` is likely intended to be imported from `gitm.optimizer.apply`.

```diff
--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -11,6 +11,7 @@
 from dataclasses import asdict, dataclass
 from pathlib import Path
 from typing import Any
+from gitm.optimizer.apply import knob_kind
 
 from gitm._paths import runs_dir, traces_dir
 from gitm.agents.autoresearch import (

gitm/tracer/vllm_stats.py

Potential Issue: Removal of vLLM V1 scheduler stats

The changes remove specific logic for reading vLLM V1 scheduler statistics (_v1_scheduler_stats) and simplify the search paths for schedulers in _schedulers. If compatibility with vLLM V1 is still a requirement, this change might break the ability to collect detailed scheduler metrics from V1 engines. If vLLM V1 is no longer a supported target, this change is acceptable.

Consider adding a comment or documentation to clarify the supported vLLM versions for metric collection, or re-introduce conditional logic if V1 support is desired.

--- a/gitm/tracer/vllm_stats.py
+++ b/gitm/tracer/vllm_stats.py
@@ -83,50 +83,11 @@
 
 
 def _schedulers(engine: Any) -> list[Any]:
     """Resolve the engine's scheduler(s) as a list (vLLM may keep one per PP stage)."""
     sched = _first_attr(
-        engine,
-        # vLLM V0.
-        "scheduler",
-        "engine.scheduler",
-        "llm_engine.scheduler",
-        # vLLM V1 (in-process, VLLM_ENABLE_V1_MULTIPROCESSING=0): the scheduler
-        # lives inside the EngineCore, not on the LLMEngine. Paths vary across
-        # v0.2x point releases — GPU-validate the exact one on the target build.
-        "llm_engine.engine_core.engine_core.scheduler",
-        "llm_engine.engine_core.scheduler",
-        "engine_core.engine_core.scheduler",
-        "engine_core.scheduler",
+        engine, "scheduler", "engine.scheduler", "llm_engine.scheduler"
     )
     if sched is None:
         return []
     return list(sched) if isinstance(sched, list | tuple) else [sched]
 
 
-def _v1_scheduler_stats(scheduler: Any) -> dict[str, Any]:
-    """vLLM V1 stats via ``scheduler.make_stats()`` — V1 doesn't keep the V0
-    running/waiting deques in the same shape, but exposes a stats object with
-    ``num_running_reqs`` / ``num_waiting_reqs`` / ``kv_cache_usage``. Best-effort;
-    returns only the fields it actually found. GPU-validate the attr names on the
-    target vLLM build (they've shifted across v0.2x).
-    """
-    make = getattr(scheduler, "make_stats", None)
-    if not callable(make):
-        return {}
-    try:
-        stats = make()
-    except Exception:
-        return {}
-    if stats is None:
-        return {}
-    out: dict[str, Any] = {}
-    for field_name, attr in (("num_running", "num_running_reqs"),
-                             ("num_waiting", "num_waiting_reqs")):
-        v = getattr(stats, attr, None)
-        if isinstance(v, int):
-            out[field_name] = v
-    ku = getattr(stats, "kv_cache_usage", None)
-    if isinstance(ku, int | float):
-        out["gpu_cache_usage"] = float(ku)
-    return out
-
-
 def _max_num_seqs(engine: Any) -> int | None:
     val = _first_attr(
         engine,
         "scheduler_config.max_num_seqs",
         "engine.scheduler_config.max_num_seqs",
@@ -174,16 +134,6 @@
         if usage is not None:
             sample.gpu_cache_usage = usage
             saw_any = True
 
-        # vLLM V1: fill running / waiting / cache from the scheduler's stat object
-        # where the VO deques weren't exposed (they read empty on V1)
-        for sch in schedulers:
-            for field_name, val in _v1_scheduler_stats(sch).items():
-                if getattr(sample, field_name) is None:
-                    setattr(sample, field_name, val)
-                    saw_any = True
-
     # Total unfinished — a stable public method on LLMEngine across versions.
     getter = _first_attr(
         engine,
         "get_num_unfinished_requests",
         "engine.get_num_unfinished_requests",

gitm/workloads.py

No issues found.

tests/test_autoresearch.py

No issues found.

tests/test_run_loop_workload.py

No issues found.

tests/test_vllm_stats_v1.py

No issues found (file was deleted).

tests/test_workload_bootstrap.py

No issues found.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_vllm_decode_factory missing return before new code (loop.py / workloads.py)
The diff adds run.engine = llm / return run in workloads.py, but the original function presumably already had a return statement. Without seeing the full context it's hard to be certain, but the missing blank line before def _vllm_synthetic_runner and the placement suggest the original return run was deleted and replaced — verify the factory doesn't fall through to None.

StochasticProposer weight check is wrong

if not any(weights):
    return []

weights is a list of floats (1.0 or self._epsilon). When epsilon > 0, any(weights) is always True even with zero affine knobs — the condition never fires in the meaningful case. The guard should be if not eligible or if not any(w > 0 for w in weights).

_value_grid for int=0 default uses base=1, producing values that may be nonsensical

base = d if isinstance(d, int | float) and d not in (0, False) else 1
raw = [base * m for m in _GRID_MULTIPLIERS]  # → [0.5, 2, 4]

For cpu_offload_gb (default=0), this yields [2, 4] GB — plausible but accidental. For compilation_config (default=0), this is overridden by the explicit grid, but any other zero-default int without an explicit grid silently gets [2, 4]. This should be documented or the sentinel logic made explicit.

autoresearch_v0 records target.op on all results even when the op is absent from the trace
target_op passed to the proposer is None when the op is absent (correct), but aimed_at = target.op if target is not None else None is always the raw op regardless. So r.target_op and r.spec.applies_to_kernels will be inconsistent for the off-trace case — the test test_off_trace_residual_op_is_not_tagged even asserts r.target_op == "paged_attention" while applies_to_kernels == [], which is the documented intent but silently contradicts the field name.

largest_residual calls max() before checking for all-negative means

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))
mean = sum(values) / len(values)
if mean <= 0:
    return None

max() on an empty by_op (impossible given the earlier guard) aside, the pattern is fine — but if every op has mean <= 0, the max() still runs and computes the "least bad" op before discarding it. Minor inefficiency, not a bug, but a single-pass approach would be cleaner.


🔒 Security

_unenactable in loop.py uses knob_kind without an import guard
knob_kind is referenced in the closure but doesn't appear in the diff's imports. If it's resolved at call time from an outer scope, a missing import will raise NameError only when an autoresearch candidate with a structural knob is encountered — a silent latent failure in a path that's hard to exercise in tests.

autoresearch.json written with write_text (no atomic replace)
A crash mid-run leaves a truncated JSON file that future runs or tooling will silently misparse. Use a temp-file + rename pattern.


⚡ Performance

_op_present is O(kernels) per call, called once per autoresearch_v0 invocation — acceptable now, but if it's ever called per-proposal (e.g. in a future loop) it should be memoized or converted to a set lookup.

GenerativeProposer.propose builds the full candidate list then slices with out[:self._max] — generates all value-grid points for all affine knobs before discarding. For a large EngineArgs surface this could be significant; a generator/islice pattern would be cheaper.


📊 Reproducibility

StochasticProposer seed is an __init__ parameter, not per-call
The same StochasticProposer instance will produce identical samples across calls (same rng = random.Random(self._seed) every time propose() is called). If the loop calls propose() multiple times in one run, all calls return the same candidates. Consider seeding per-call with seed + call_counter or accepting a per-call seed override.

_visible_gpu_count via torch.cuda.device_count() is not stable under CUDA_VISIBLE_DEVICES
If the environment masks GPUs between the source-construction call and the actual engine build, the filter is stale. Document that gpu_count should be passed explicitly in the loop rather than autodetected.


💡 Suggestions

_field_kind_and_choices silently swallows all exceptions

except Exception:
    pass

A broken annotation object will silently fall back to a string-match kind. At minimum log a debug warning so annotation-parse failures are visible during development.

FallbackProposer has no logging when it falls back
Callers can't tell whether the generative path produced results or whether the static table fired. A single logger.debug on fallback would make this observable in loop logs.

autoresearch_v0 policy parameter default creates a new Policy() per call

ranked = select_interventions(trace, proposals, policy or Policy(), ...)

Fine for now, but if Policy.__init__ ever becomes expensive (e.g. loads config), this silently regresses. Better to default to None and construct once at the call site — which is already what the signature does; just move the or Policy() to the caller or make select_interventions handle None.

Test file is cut offtest_off_trace_residual_op_is_not_tagged ends mid-assertion (r.spec.applies_to_ke), so the full test suite coverage isn't reviewable.

_RULES table entry for compilation_config uses integer 3 as the value
vLLM's compilation_config accepts a CompilationConfig object or a dict, not a bare int, in recent versions. The static table comment says "verified against docs.vllm.ai" but this should be re-checked — a type mismatch here will produce a confusing engine error rather than a clean rollback.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_vllm_decode_factory missing return statement before refactor (gitm/workloads.py):

run.engine = llm
llm.gitm_throughput_fn = _throughput
llm.gitm_restart_fn = _restart
return run

def _vllm_synthetic_runner(...)  # ← no blank line, likely a merge artifact

The diff shows the new return run block but _vllm_decode_factory previously had no return — if the original function fell through, all callers got None. Confirm the prior code path was complete.

test_autoresearch_targets_largest_residual_op is truncated (tests/test_autoresearch.py, line 855):

run = autoresearch(make_trace(events=events), applicator=applica  # cut off

The test file is incomplete — this will be a SyntaxError at import time, breaking the entire test module.

_value_grid uses isinstance(d, int | float) union syntax — this requires Python 3.10+. If the repo targets 3.8/3.9 this is a runtime TypeError, not caught by linting since it's inside isinstance.

largest_residual uses max() before checking if all means are ≤ 0 — it calls max(by_op.items(), key=...) then checks the winner's mean. This is correct but silently returns None even if e.g. one op has mean +0.01. That's intentional per docs, but the zero-threshold means any positive residual (however tiny) triggers targeting, which may not be desirable.

StochasticProposer: any(weights) check is wrong for epsilon > 0. When epsilon > 0, weights is never all-zero (every eligible knob gets at least epsilon), so the guard only catches the epsilon=0 + nothing-affine case. If eligible is empty, weights is empty and any([]) is False — this works, but the comment says "All-zero weight (epsilon=0, nothing affine)" which is misleading since the real guard is "eligible is empty."


🔒 Security

apply_error field surfaces raw exception messages into autoresearch.json and the report — exception strings from vLLM engine builds can contain file paths, model names, or config values. Low severity in an internal benchmarking tool, but worth sanitizing before any external surfacing.


⚡ Performance

_op_present scans all kernels on every proposal — called once per result in autoresearch_v0, but trace.kernels() may be an expensive call. Hoist it outside the loop or cache it.

GenerativeProposer.propose builds the full candidate list then slices — for large EngineArgs surfaces with max_candidates=24, the list comprehension generates all combinations before truncating. Consider an islice-based generator to avoid materializing hundreds of specs.


📊 Reproducibility

StochasticProposer seed is instance-level, not per-call — the same StochasticProposer(seed=0) instance will produce identical output on every propose() call regardless of run. If the loop calls it multiple times across phases, all calls return the same samples. A per-call seed derivation (e.g. seed ^ hash(bottleneck_class)) would give varied-but-reproducible draws per class.

_visible_gpu_count() is called at knob-generation time, not at construction — if GPU availability changes between VLLMKnobSource construction and knobs() call, the filter result is inconsistent. Minor in practice.


💡 Suggestions

_knobs_from_engine_args uses f.type for annotationdataclasses.Field.type is a string under from __future__ import annotations (PEP 563), so _knob_domain(f.type, domain) receives a string like "Optional[int]" rather than a real type object, making _field_kind_and_choices's typing.get_origin check always fail. The argparse domain path works around this for vLLM, but the annotation fallback is effectively dead for stringified annotations. Use typing.get_type_hints() instead.

No error handling if autoresearch.json write failsrun_dir / "autoresearch.json" is written with .write_text() without try/except. A disk-full or permissions error here would abort Phase 5 (the report write), losing all results silently.

_unenactable closure in loop.py references live_restart_fn — this name must be defined earlier in run_loop. If the variable name changes in the surrounding code, this silently becomes a NameError at runtime (not at definition time). A comment referencing where live_restart_fn is set would help maintainability.

propose() docstring says "the op is the caller's job to validate against the trace" but autoresearch_v0 does this validation internally via _op_present. The public propose() function is also called directly (per the API docs) where callers may not know to validate — either make propose() trace-aware or strengthen the docstring warning.

FallbackProposer has no logging/telemetry when falling back — silent fallback from EngineArgsProposer to TableProposer makes it hard to diagnose why the generative path produced nothing (empty class? all knobs in catalog? no affine knobs?).

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -11,6 +11,7 @@ from __future__ import annotations
 import json
 import os
 import re
 import time
 import uuid
 from dataclasses import asdict, dataclass
 from pathlib import Path
 from typing import Any
 
 from gitm._paths import runs_dir, traces_dir
 from gitm.agents.autoresearch import (
     AutoresearchRun,
     EngineArgsProposer,
     FallbackProposer,
     TableProposer,
     autoresearch,
     classify_bottleneck,
 )
 from gitm.agents.policy import Policy, select_interventions
 from gitm.kernels.library import load_library
 from gitm.optimizer.apply import (
     Applicator,
     DryRunApplicator,
     LiveEngineApplicator,
     apply_intervention,
+)
+from gitm.optimizer.apply import knob_kind
 from gitm.optimizer.attribution import attribute
 from gitm.optimizer.deviation import deviation_summary, deviation_trace, write_deviation_jsonl
 from gitm.optimizer.monitor import (

Reasoning for the change:
The _unenactable function in run_loop uses knob_kind(spec.knob) to determine if a knob is structural. However, knob_kind is defined in gitm.optimizer.apply and was not imported into gitm.scheduler.loop.py, leading to a NameError. This change adds the necessary import.


--- a/gitm/tracer/vllm_stats.py
+++ b/gitm/tracer/vllm_stats.py
@@ -83,20 +83,59 @@ def _first_attr(obj: Any, *paths: str) -> Any:
 def _len_or_none(x: Any) -> int | None:
     try:
         return len(x)
     except TypeError:
         return None
 
 
 def _schedulers(engine: Any) -> list[Any]:
     """Resolve the engine's scheduler(s) as a list (vLLM may keep one per PP stage)."""
     sched = _first_attr(
-        engine, "scheduler", "engine.scheduler", "llm_engine.scheduler"
+        engine,
+        # vLLM V0.
+        "scheduler",
+        "engine.scheduler",
+        "llm_engine.scheduler",
+        # vLLM V1 (in-process, VLLM_ENABLE_V1_MULTIPROCESSING=0): the scheduler
+        # lives inside the EngineCore, not on the LLMEngine. Paths vary across
+        # v0.2x point releases — GPU-validate the exact one on the target build.
+        "llm_engine.engine_core.engine_core.scheduler",
+        "llm_engine.engine_core.scheduler",
+        "engine_core.engine_core.scheduler",
+        "engine_core.scheduler",
     )
     if sched is None:
         return []
     return list(sched) if isinstance(sched, list | tuple) else [sched]
 
 
+def _v1_scheduler_stats(scheduler: Any) -> dict[str, Any]:
+    """vLLM V1 stats via ``scheduler.make_stats()`` — V1 doesn't keep the V0
+    running/waiting deques in the same shape, but exposes a stats object with
+    ``num_running_reqs`` / ``num_waiting_reqs`` / ``kv_cache_usage``. Best-effort;
+    returns only the fields it actually found. GPU-validate the attr names on the
+    target vLLM build (they've shifted across v0.2x).
+    """
+    make = getattr(scheduler, "make_stats", None)
+    if not callable(make):
+        return {}
+    try:
+        stats = make()
+    except Exception:
+        return {}
+    if stats is None:
+        return {}
+    out: dict[str, Any] = {}
+    for field_name, attr in (("num_running", "num_running_reqs"),
+                             ("num_waiting", "num_waiting_reqs")):
+        v = getattr(stats, attr, None)
+        if isinstance(v, int):
+            out[field_name] = v
+    ku = getattr(stats, "kv_cache_usage", None)
+    if isinstance(ku, int | float):
+        out["gpu_cache_usage"] = float(ku)
+    return out
+
+
 def _max_num_seqs(engine: Any) -> int | None:
     val = _first_attr(
         engine,
         "scheduler_config.max_num_seqs",
         "engine.scheduler_config.max_num_seqs",
         "llm_engine.scheduler_config.max_num_seqs",
         "vllm_config.scheduler_config.max_num_seqs",
     )
     return int(val) if isinstance(val, int) and val > 0 else None
 
@@ -174,14 +204,13 @@ def read_scheduler_stats(engine: Any, *, t_ns: int = 0) -> SchedulerSample | Non
         if usage is not None:
             sample.gpu_cache_usage = usage
             saw_any = True
 
-        # vLLM V1: fill running / waiting / cache from the scheduler's stat object
-        # where the VO deques weren't exposed (they read empty on V1)
-        for sch in schedulers:
-            for field_name, val in _v1_scheduler_stats(sch).items():
-                if getattr(sample, field_name) is None:
-                    setattr(sample, field_name, val)
-                    saw_any = True
+        # vLLM V1: fill running / waiting / cache from the scheduler's stat object
+        # where the V0 deques weren't exposed (they read empty on V1)
+        for sch in schedulers:
+            for field_name, val in _v1_scheduler_stats(sch).items():
+                if getattr(sample, field_name) is None: # Only set if not already found by V0 path
+                    setattr(sample, field_name, val)
+                    saw_any = True
 
     # Total unfinished — a stable public method on LLMEngine across versions.
     getter = _first_attr(

Reasoning for the change:
The original code included logic to handle vLLM V1 scheduler statistics, which are exposed via a make_stats() method and potentially deeper engine_core paths. This logic was removed in the provided diff, along with the corresponding test file (tests/test_vllm_stats_v1.py). This change could lead to a regression in scheduler stats collection if the target vLLM version uses these V1-style paths. Reverting these removals ensures broader compatibility with different vLLM versions and prevents loss of valuable monitoring data.


--- /dev/null
+++ b/tests/test_vllm_stats_v1.py
@@ -0,0 +1,50 @@
+"""Read scheduler stats from a vLLM V1-shaped engine.
+
+V1 keeps the scheduler inside EngineCore and exposes a stats object via
+make_stats() (num_running_reqs / num_waiting_reqs / kv_cache_usage) instead of
+the V0 running/waiting deques. Fake that shape to pin the read path off-GPU; the
+exact attr names still need GPU validation on the target vLLM build.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from gitm.tracer.vllm_stats import read_scheduler_stats
+
+
+def _v1_engine(running: int, waiting: int, cache: float, max_seqs: int = 256):
+    stats = SimpleNamespace(
+        num_running_reqs=running, num_waiting_reqs=waiting, kv_cache_usage=cache
+    )
+    scheduler = SimpleNamespace(make_stats=lambda: stats)
+    # llm.llm_engine.engine_core.engine_core.scheduler (in-process V1)
+    return SimpleNamespace(
+        llm_engine=SimpleNamespace(
+            engine_core=SimpleNamespace(engine_core=SimpleNamespace(scheduler=scheduler))
+        ),
+        scheduler_config=SimpleNamespace(max_num_seqs=max_seqs),
+    )
+
+
+def test_reads_v1_stats_object():
+    s = read_scheduler_stats(_v1_engine(running=3, waiting=12, cache=0.87), t_ns=0)
+    assert s is not None
+    assert s.num_running == 3
+    assert s.num_waiting == 12
+    assert s.gpu_cache_usage == 0.87
+    assert s.batch_occupancy == 3 / 256  # occupancy derived from V1 num_running
+
+
+def test_none_engine_still_none():
+    assert read_scheduler_stats(None) is None
+
+
+def test_v1_engine_without_stats_is_none():
+    # a V1-shaped engine whose scheduler exposes nothing readable -> None, no crash.
+    empty = SimpleNamespace(
+        llm_engine=SimpleNamespace(
+            engine_core=SimpleNamespace(engine_core=SimpleNamespace(scheduler=object()))
+        )
+    )
+    assert read_scheduler_stats(empty) is None

Reasoning for the change:
This test file was deleted in the provided diff. Since the _v1_scheduler_stats function and related logic are being restored in gitm/tracer/vllm_stats.py to maintain compatibility with vLLM V1, the corresponding tests should also be restored to ensure that this functionality continues to work as expected.

nicholaslawrence-hub and others added 4 commits July 8, 2026 12:38
The value grid was blind: a ½×/2×/4× ladder that invents values for enum knobs
and list-valued args, which then fail to apply and waste rollback-gated trials.

Read each field's valid domain from its vLLM CLI argument instead:
- argparse `choices=` → the real enum domain, so the grid proposes only values
  that can apply (not an invented ladder point).
- argparse `type=` → sharpens int/float when the annotation is opaque.
- `nargs` → skip list-valued args (not scalar knobs).

Split the extraction into `_knobs_from_engine_args` / `_argparse_domains` /
`_knob_domain` so it's tested against a fake EngineArgs-like class without vLLM
present. Honest limit: argparse carries no numeric min/max, so unconstrained
ints/floats still use the ladder. Offline (no vLLM) path unchanged — frozen
fallback catalog. Docs reconciled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On platforms whose CUPTI doesn't attribute CUDA-graph-replayed kernels, the
tracer records an empty trace and the loop honestly reports no-data. Setting
GITM_VLLM_ENFORCE_EAGER=1 builds the engine (and every restart-A/B candidate,
via the shared _base_kwargs) with enforce_eager=True — decode kernels launch
individually so CUPTI captures them. Also skips torch.compile + graph capture,
so engine builds are much faster, fitting more candidates in a given budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real H100/Colab runs showed most generated structural candidates rolling back
unmeasured (apply failed, not a measured loss) — several because the knob
(tensor_parallel_size, prefill_context_parallel_size, ...) only makes sense
with more than one GPU, and the single-GPU box's engine build has no topology
to satisfy. Every such trial is a wasted restart-A/B on a candidate that was
never enactable.

Filter EngineArgs fields whose name indicates multi-GPU-only relevance
(_MULTI_GPU_HINTS: parallel_size, tensor_parallel, pipeline_parallel,
data_parallel, context_parallel, expert_parallel) out of the searchable
surface when the box has fewer than 2 GPUs. GPU count is autodetected via
torch.cuda.device_count() (default 1 when it can't tell — undercounting only
skips a possibly-valid knob, the safe direction) or overridable explicitly via
VLLMKnobSource(gpu_count=...) / EngineArgsProposer(gpu_count=...). Offline
fallback catalog is unaffected (no multi-GPU knobs in it).

9 new tests. Docs reconciled: notes the filter, and the remaining honest gap
(checkpoint- or VRAM-dependent knobs like quantization=awq or the restart A/B's
second-engine memory requirement aren't covered by this filter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -500,6 +500,9 @@
     """Read each EngineArgs field's valid domain from ``add_cli_args`` (best-effort).
 
     vLLM builds its CLI from the same dataclass, so the argparse actions are the
     authoritative source of choices/types — far better than string-matching the
     annotation. Returns ``{}`` if the class has no ``add_cli_args`` or it raises.
     """
     try:
         import argparse
 
         parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())  # type: ignore[attr-defined]
+        # NOTE: Accessing `_actions` is an internal implementation detail of argparse.
+        # This is done to introspect the CLI argument definitions (choices, type, nargs)
+        # which are not exposed via a public API on the dataclass itself.
     except Exception:
         return {}
     out: dict[str, _ArgDomain] = {}

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

workloads.py — missing return / syntax truncation
The diff ends mid-sentence in tests/test_autoresearch.py (applicator=applica), but more critically _vllm_decode_factory has a structural issue: the three assignments at the bottom (run.engine = llm, etc.) appear after _restart is defined but the function never had a return run before this patch — the diff shows return run was added, but it's dangling without the prior run = WorkloadRunner(...) construction being visible. Verify the return run is actually reachable and not orphaned after the factory's exception handlers.

StochasticProposerany(weights) is always truthy when epsilon > 0

weights = [1.0 if _affine(...) else self._epsilon for k, _grid in eligible]
if not any(weights):
    return []

When epsilon > 0 (the default is 0.15), any(weights) is always True even if zero knobs are affine to the class. The guard only protects the epsilon=0 case. The intent — "if there's nothing worth sampling, bail" — is not achieved for the common case. Consider if not eligible: instead, or checking that sum(weights) > 0 after zeroing non-affine weights when epsilon=0.

_value_gridisinstance(d, int | float) and d not in (0, False) uses identity not equality
d not in (0, False) uses ==, but False == 0 in Python, so a bool default False falls through to base = 1 correctly — however a bool default True (d = True) would give base = True → multiplied floats → _GRID_MULTIPLIERS scaling with 0.5/2.0/4.0, yielding {0, 2, 4} cast to int. The bool branch above handles kind == "bool" but if a bool field is misclassified as "int" (plausible given the best-effort annotation parsing), you'd get nonsensical integer grid values.

classify_bottlenecksc_score >= mem_score tie-break always picks idle_stall
If both scores are exactly equal (e.g. both at 1.0), the workload is classified idle_stall. The threshold docs don't mention this tie-break. Low severity given the heuristic nature, but worth a comment.


🔒 Security

loop.pyapply_error from raw exception message written to report

if r.measured_delta is None and r.apply_error:
    evidence += f"; apply failed: {r.apply_error}"

apply_error is a raw exception .message surfaced into the causal-evidence text and autoresearch.json. If the engine raises an exception containing user-supplied paths or credentials (e.g. a model path, API key in an env var echoed by vLLM), those land verbatim in the report. Consider truncating/sanitizing before inclusion.


⚡ Performance

_searchable() calls _value_grid(k) twice per knob
In GenerativeProposer.propose, _searchable() filters by _value_grid(k) being non-empty, then the comprehension calls _value_grid(knob) again for each surviving knob. For a large EngineArgs surface this is redundant work. _searchable() could return list[tuple[Knob, list]] pairs.

StochasticProposerrng.choices(eligible, weights=weights, k=1)[0] in a tight loop
Called up to max(n*4, len(eligible)*4) times. random.choices with k=1 recomputes the cumulative weight table on every call. Pre-draw all samples at once: rng.choices(eligible, weights=weights, k=max_attempts).


📊 Reproducibility

StochasticProposer seed is per-instance, not per-call
The seed is fixed at construction time (seed=0 default). Two calls to .propose() on the same instance with the same arguments produce different results because the rng state is recreated fresh each time from random.Random(self._seed) — actually this is fine. But the default seed=0 means all StochasticProposer() instances produce identical sequences unless the caller varies the seed, creating a false impression of diversity across runs. Document this explicitly or derive the seed from the run ID.

EngineArgsProposer default max_candidates=24 with deterministic ordering
The first 24 candidates are taken by list-slice (out[:self._max]). The order depends on dataclasses.fields() iteration order, which is deterministic but tied to the vLLM version. A vLLM upgrade silently changes which 24 candidates are proposed. Consider sorting by a stable key (e.g. knob name) before slicing.


💡 Suggestions

autoresearch_v0reject veto result is not included in AutoresearchResult
When reject(c.spec) fires, reason is set and applicable=False is recorded — but the result's applicable field is applied is not None, which would be False for both gate-rejected and caller-rejected candidates. There's no way to distinguish "gate rejected" from "caller's reject fn rejected" in the result without reading rejected_reason. Consider adding a rejection_source: Literal["gate","caller"] | None field.

_knobs_from_engine_args — uses f.type (string under from __future__ import annotations)

kind, choices = _knob_domain(f.type, domain)

vLLM uses from __future__ import annotations, so f.type is a string (the raw annotation text), not a resolved type object. _field_kind_and_choices / typing.get_origin will never fire for Literal[...] annotations in that case. The code acknowledges this in the docstring ("won't resolve here") but it means the enum-domain extraction via annotation is dead code for vLLM fields — only _argparse_domains actually works. The fallback path is correct, but the annotation branch is misleading.

largest_residualmax() on an empty dict raises

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

This is only reached when res.per_kernel is non-empty, so by_op is guaranteed non-empty. But a future refactor removing the early-return guard would silently raise ValueError. Add if not by_op: return None for defensive correctness.

No test for FallbackProposer fallback behaviour
The test file (truncated, but visible up to line ~855) doesn't appear to include a test that FallbackProposer actually falls through to secondary when primary returns []. This is the critical composition path used by the loop.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_shutdown early-exit logic change is semantically different
The original return after finding the first valid shutdown path meant "stop after the first successful path." The replacement break exits the for path in ... loop but still runs the gc.collect() / empty_cache() block — that's probably fine/intended, but the old return also skipped GPU cleanup, which may have been deliberate (e.g. the baseline engine is still alive in parallel mode). Worth confirming this doesn't flush the baseline engine's CUDA allocations prematurely.

serial_restart path leaves _prev set on None-engine
In _apply_structural_knob, if self._restart_fn returns None in serial mode, the code calls restore_baseline() and sets self._prev = None, but it already set self._prev = ("serial_restart", restore_baseline) before the call. If restore_baseline() itself throws, _prev is left in the ("serial_restart", ...) state pointing to a closed engine.

_vllm_decode_factory loses its return value
The diff adds run.engine = llm etc. and ends with return run, but the earlier code path that built llm was llm = _build_engine(...) — the original function body after the diff hunk presumably had return run elsewhere. Verify the truncated diff doesn't accidentally drop the runner/run construction that precedes this block; the snippet jumps from building llm to assigning run.engine = llm with no visible run = construction.

largest_residual calls max() before checking length

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

If res.per_kernel is non-empty but every entry has an empty r_kt list (shouldn't happen by construction, but a defensive check is cheap), len(kv[1]) is 0 and this raises.

StochasticProposer weight check is wrong

if not any(weights):

weights is a list of floatany([0.0, 0.0]) is False but any([0.15, 0.0]) is True. This correctly handles epsilon=0 with no affine knobs, but if epsilon is a small positive float like 0.001, any(weights) is still True even though rng.choices with near-zero weights may loop for max_attempts producing nothing useful. Not a crash, but a silent spin worth documenting.


🔒 Security

restart_mode from env without validation at call site

restart_mode=os.environ.get("GITM_RESTART_MODE", "parallel"),

LiveEngineApplicator.__init__ does validate it (raise ValueError), so injection is caught — but a typo silently hard-crashes the 24-hour loop run. Consider defaulting to "parallel" with a logged warning rather than a hard raise inside a long job.

apply_error surfaces raw exception messages into autoresearch.json and the report
Exception messages from engine builds can contain model paths, environment details, or credentials. These land unredacted in the JSON artifact and the causal-evidence text in the report. Fine for internal tooling, worth a note if reports are shared.


⚡ Performance

gc.collect() + empty_cache() on every _shutdown
This is now called after every engine teardown, including hotswap rollbacks that never touched GPU memory. For a tight A/B loop this adds measurable latency (CUDA sync + Python GC pause) per candidate. Gate it behind restart / serial_restart tags only.

_engine_arg_knobs builds a fresh argparse parser on every EngineArgsProposer call
_argparse_domains constructs a full ArgumentParser and iterates all actions each time knobs() is called. For vLLM's large EngineArgs surface this is non-trivial. Cache the result (e.g. functools.lru_cache on _knobs_from_engine_args keyed by class + gpu_count).


📊 Reproducibility

StochasticProposer seed is an instance attribute, not reset between calls

rng = random.Random(self._seed)

rng is re-created from self._seed on each propose() call, so repeated calls with the same seed produce identical outputs — good. But if propose() is called twice in one run (e.g. FallbackProposer retries), both calls return the same candidates, wasting trials. Document this or advance the seed per call.

GITM_RESTART_MODE is read at run_loop call time
The mode is read from the environment inside the function, not from LoopConfig. This means two concurrent loop runs in the same process share the mode, and changing it mid-run (unlikely but possible in tests) is invisible. Move it into LoopConfig.


💡 Suggestions

_unenactable in loop.py references knob_kind but it's not imported in the diff
The lambda uses knob_kind(spec.knob) — verify this is imported/defined in scope; it's not visible in the shown diff.

autoresearch.json is written unconditionally even for empty runs
When the budget is already exhausted, ar_run = AutoresearchRun(..., results=[]) and the file is still written. That's fine, but an empty results list with no target silently looks like "ran, found nothing" vs "skipped". Add a "skipped": true key in the budget-exhausted branch.

apply_error is set from applied.error but ApplyResult field name isn't confirmed in the diff

apply_error=applied.error if applied else None,

The ApplyResult dataclass isn't shown in this diff — confirm the field is .error and not .apply_error or .exception to avoid a silent AttributeError that surfaces as None everywhere.

No test for serial-restart rollback path
The test file is truncated, but the new serial_restart restore path in LiveEngineApplicator (the most complex new code path) is not covered by the visible tests. Specifically: what happens when restore_baseline() raises during rollback?

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Gemini

## `benchmarks/README.md`

```diff
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -1,125 +1,105 @@
-# We Gave Our Runtime Four GPU Workloads and No Instructions. It Diagnosed Each and Proved Every Fix.
+# Benchmarks
 
-*The trace picks the lever. A gated A/B proves it.*
-
-**+32.4% on HFT, byte-identical on 400M events. +37% on nuScenes. Up to +427% on KITTI. Zero human tuning, every failed change rolled back.**
-
-*July 2026*
-
-[Reproduce our benchmarks](#try-it-yourself)
-
----
+We Gave Our Runtime Four GPU Workloads and No Instructions. It Diagnosed Each and Proved Every Fix.
+The trace picks the lever. A gated A/B proves it.
++32.4% on **HFT Limit Order Books**, byte-identical on 400M events. +37% on **nuScenes**. Up to +427% on **KITTI**. Zero human tuning, every failed change rolled back.
 
 We think most GPU fleets run far below the hardware they are paying for, and that the waste is a systems problem, not a kernel problem. The GPU is rarely slow because the math is slow. It is slow because it waits: launching thousands of tiny kernels one at a time, stalling at synchronization points, moving memory it did not need to move, or running work in series that could have overlapped.
-
 Our runtime does one thing: it closes the loop between diagnosis and proof.
 
 - **Observe.** Profile the workload from its own telemetry (CUPTI, NVML). No model-code changes or source access.
 - **Diagnose.** Read the dominant bottleneck off the trace: launch-bound, compute-bound, or serialized.
 - **Apply.** The candidate intervention selected for that bottleneck.
 - **Verify.** A gated A/B on real hardware, best-of-N to suppress jitter.
 - **Keep or roll back.** Keep only if the candidate is both faster and output-equivalent. Otherwise revert and report nothing.
 
 Every number in this post is a kept decision, not a guess.
-
-A good engineer can tune one workload once. A fleet is thousands of workloads changing weekly: new models, new request mixes, new kernels, new traffic shapes, new failure modes. That is why this has to be a runtime loop, not a consulting pass. The loop earns its place because it runs continuously, per job, where hand-tuning gives up. Any one result below is useful; the point is that the same loop found all of them automatically, verified each one, and rolled back the ones that failed.
+A good engineer can tune one workload at once. A fleet is thousands of workloads changing weekly: new models, new request mixes, new kernels, new traffic shapes, new failure modes. That is why this has to be a runtime loop, not a consulting pass. The loop earns its place because it runs continuously, per job, where hand-tuning gives up. Any one result below is useful; the point is that the same loop found all of them automatically, verified each one, and rolled back the ones that failed.
 
 Two honest notes before the numbers. These are benchmark workloads, not a customer's production fleet, and what they prove is the loop, that the runtime can read what actually limits a workload and apply a safe, verified fix without touching model code. Separately, our commercial wedge is inference serving, and the same loop is now pointed there in a vLLM decode benchmark we describe at the end. We are not pretending these four workloads stand in for that one. They show the loop; the next one runs it on what we sell into.
 
-## Where the Waste Actually Lives
-
+Where the Waste Actually Lives
 Two readings from the trace tell us most of what we need. The first is whether the GPU is **launch-bound** or **compute-bound**: burning time issuing many small kernels, or genuinely busy doing large-matrix arithmetic? The second is **serialized concurrency**, our reading of how much work runs with no overlap.
 
 A serialized concurrency of 1.000 means your GPU is effectively single-threaded at the workload level: thousands of kernels standing in a single-file line, each waiting on the one before it, a piece of hardware built for massive parallelism running its work one at a time.
 
 These two readings decide the lever. A launch-bound workload wants its launches amortized. A compute-bound workload does not care about launch overhead at all, so amortizing launches buys nothing; it wants the arithmetic itself made cheaper. Pick the wrong lever and you do real work for a 0% result.
-
-## Track 1: HFT Limit Order Books (the cleanest proof)
-
+Track 1: HFT Limit Order Books (the cleanest proof)
 The strongest result first: **+32.4% throughput with byte-identical output, on 400 million events.**
-

optimize hft: 400,000,000 events on NVIDIA A100 80GB PCIe

baseline : 69,323,880 events/s
candidate: 91,786,039 events/s  (1.32x)
identical output: True
  • VERDICT: kept candidate — verified +32.4% faster, identical output
    +VERDICT: kept candidate — verified +32.4% faster, identical output
-
-Here is why it is the trust anchor. The order book is the live list of every outstanding buy and sell for an instrument; HFT churns it constantly into a stream of nanosecond-timestamped events. Real HFT flow is proprietary, so we generate a **deterministic synthetic stream per seed** with the structural properties that exercise scan-bound execution, and we say so plainly: generated data, reproducible from a seed, not a customer's tape. We are testing an execution path, not claiming market realism. On it we compute three per-symbol microstructure features (top of book, microprice, one-second VWAP).
+  Here is why it is the trust anchor. The order book is the live list of every outstanding buy and sell for an instrument; HFT churns it constantly into a stream of nanosecond-timestamped events. Real HFT flow is proprietary, so we generate a **deterministic synthetic stream per seed** with the structural properties that exercise scan-bound execution, and we say so plainly: generated data, reproducible from a seed, not a customer's tape. We are testing an execution path, not claiming market realism. On it we compute three per-symbol microstructure features (top of book, microprice, one-second VWAP).

The trace reads as launch-and-scan-bound, so the lever is not precision and not batch size. It is **doing fewer scans**, collapsing redundant passes over the book while preserving the exact output. Because the computation is deterministic, we hold it to byte-identical output, not a tolerance: two reductions form an output signature, and the candidate is kept only when that signature matches to the byte. +32.4% faster, and every byte the same. No accuracy hand-waving is possible, because the correctness bar is exact.
+Tracks 2 and 3: The Same Lever, Very Different Headroom

-## Tracks 2 and 3: The Same Lever, Very Different Headroom
-
-The edge tracks are where the diagnosis argument lands. They run the *identical* lever and get wildly different results, and the direction of that difference is exactly what the bottleneck reading predicts.
+The edge tracks are where the diagnosis argument lands. They run the identical lever and get wildly different results, and the direction of that difference is exactly what the bottleneck reading predicts.

**KITTI** is the standard autonomous-driving 3D detection benchmark: one LiDAR frame in, scored 3D boxes out. Model is PointPillars (OpenPCDet, pinned `pointpillar.yaml`, `pointpillar_7728.pth`). Its trace is unambiguous: serialized concurrency 1.000, 14,590 kernels in single file, the GPU spending its time launching rather than computing.

**nuScenes** is the heavier cousin: ten accumulated LiDAR sweeps per frame (about 10x the points), ten classes. Model is CenterPoint on a PointPillars backbone. Its baseline runs at about 2.6 frames per second versus KITTI's 9 to 11, because each frame already spends real time doing math.

Same lever on both: **frame batching**, collating several frames into one forward pass so per-frame launch cost is divided by the batch size. In eval mode the normalization is fixed and convolutions act per sample, so batching does not mix frames; it only shares the launches.
--
-| Batch | KITTI | nuScenes |
+-   
+| Batch | KITTI | nuScenes |   
| --- | --- | --- |
-| 2 | +37.3% | kept baseline (not faster) |
-| 4 | +63.2% | +7.1% |
-| 8 | +174.9% | +23.2% |
-| 16 | +275.4% | +34.0% |
-| 32 | +366.0% | +37.3% |
-| 64 | **+427.0%** | +25.8% |
-| 128 | **rolled back** | +34.0% |
-| 256+ | OOM | OOM |
--
-KITTI tops out at +427%. nuScenes tops out around +37%. Nobody tuned the lever differently. The runtime read a heavier, more compute-bound workload off the trace and the physics did the rest.
-
-**We did not choose batching because it is clever. We chose it because the trace ruled out everything else.** Batching is a known, mundane optimization; we did not invent it and we are not claiming to. What the runtime did was read the launch-bound signature off the trace and select batching *without being told*, sweep the batch dimension, measure where the headroom ran out per workload, and then **refuse it at KITTI B=128** when the accumulation order and cuDNN algorithm choice drifted a borderline detection past tolerance. The lever is boring on purpose. A person can pick it once. The runtime picks it automatically, per workload, across a fleet that no one has time to profile by hand, and knows when to stop. That is the whole difference between a tune and a substrate.
+| 2 | +37.3% | kept baseline (not faster) |  
+| 4 | +63.2% | +7.1% |  
+| 8 | +174.9% | +23.2% |  
+| 16 | +275.4% | +34.0% |  
+| 32 | +366.0% | +37.3% |  
+| 64 | +427.0% | +25.8% |  
+| 128 | rolled back | +34.0% |  
+| 256+ | OOM | OOM |  
+
+   KITTI tops out at +427%. nuScenes tops out around +37%. Nobody tuned the lever differently. The runtime read a heavier, more compute-bound workload off the trace and the physics did the rest.
+
+**We did not choose batching because it is clever. We chose it because the trace ruled out everything else.** Batching is a known, mundane optimization; we did not invent it and we are not claiming to. What the runtime did was read the launch-bound signature off the trace and select batching without being told, sweep the batch dimension, measure where the headroom ran out per workload, and then refuse it at **KITTI B=128** when the accumulation order and cuDNN algorithm choice drifted a borderline detection past tolerance. The lever is boring on purpose. A person can pick it once. The runtime picks it automatically, per workload, across a fleet that no one has time to profile by hand, and knows when to stop. That is the whole difference between a tune and a substrate.
That B=128 rollback sits in the table as `rolled back`, not quietly dropped. A results table with no failures in it is a marketing table, not a benchmark.

-## How We Keep Ourselves Honest
-
+How We Keep Ourselves Honest

-**We gate on correctness, not just speed.** For the deterministic HFT track that means byte-identical output. For the neural detectors, where inference across a different batch shape is never bit-exact, the gate today checks that per-frame detection counts match and sorted confidence scores agree within a small window. We will say what that is not: it is not yet a preserved-mAP (KITTI) or preserved-NDS (nuScenes) guarantee. Hardening the detector gate to a task-accuracy invariant is on the list, and where we have those deltas we will publish them.
+**We gate on correctness, not just speed.** For the deterministic HFT track that means byte-identical output. For the neural detectors, where inference across a different batch shape is never bit-exact, the gate today checks that per-frame detection counts match and sorted confidence scores agree within a small window. 
+
+**We will say what that is not:** it is not yet a preserved-mAP (KITTI) or preserved-NDS (nuScenes) guarantee. Hardening the detector gate to a task-accuracy invariant is on the list, and where we have those deltas we will publish them.

**We show the failures.** Rollbacks appear as `rolled back`. Out-of-memory batches appear as `OOM`. Nothing is omitted to make the table cleaner.

**We report best-of-N, and we publish the spread.** Best-of-N isolates a systems change from scheduler jitter, which is standard, but it is also the number most worth auditing, so we publish per-run variance alongside the reproducibility artifacts rather than asking you to trust the best sample.

**We name the model, the hardware, and the config.** Pinned checkpoints, pinned configs, named GPUs, seeded data. The commands below regenerate every number.

-## What This Does Not Yet Prove, and What's Next
-
+What This Does Not Yet Prove, and What's Next

Two honest gaps.

First, the compute-bound case. Every track above is launch- or scan-bound, which is where amortizing work pays off. What does the runtime do when the GPU is genuinely busy with arithmetic? The answer should be a completely different lever, and we are running that now on OpenFold (the open-source AlphaFold2 reimplementation), whose Evoformer is dense attention and large matmuls. Frame batching would buy nothing there; the right lever is **bf16 precision**, gated on median plDDT holding within tolerance. We are deliberately not publishing a number until it clears the same gated A/B the others did. An early prediction put it near +17% and an earlier ad-hoc run came back much higher, and rather than pick the flattering one we are re-measuring honestly first.

Second, the commercial edge. None of the four tracks above is an inference-serving workload, and our wedge is inference serving: helping providers get more sellable throughput from the GPUs they already own. The same gated loop is now pointed there. The inference-serving benchmark applies the identical gate to vLLM and SGLang decode, measured in tokens per second under p95 and p99 latency constraints, with named model, GPU count, and request mix, before and after. Inference providers run thousands of decode jobs that drift constantly as models, quantization, and traffic change, which is exactly the condition where a continuous runtime loop compounds and hand-tuning cannot keep up. This post shows the loop working across bottleneck types. The next applies it to the workload we sell into.

-## Try It Yourself
-
+Try It Yourself
Every result regenerates from a single command per track. The HFT A/B, on a dataset you regenerate deterministically from a seed:
--
-```sh
+```
python -m gitm.benchmarks.hft.generate --events 1_000_000_000 --seed 42 --out ./hft_1b_seed42
gitm run --workload hft --optimize --seed 42 --stage ./hft_1b_seed42 --max-events 400_000_000

--
The KITTI batch sweep:

-sh +
for B in 1 2 4 8 16 32 64 128 256 512; do
AB=$(( B < 64 ? 64 : B ))
GITM_EDGE_CKPT=.../pointpillar_7728.pth
GITM_EDGE_CFG=.../pointpillar.yaml
GITM_EDGE_FRAMES=$AB GITM_EDGE_AB_FRAMES=$AB GITM_EDGE_BATCH_SIZE=$B
python -m gitm.cli run --workload kitti --report kitti_b$B.md
done

--
 **Want this run against your own workload?** Point us at one GPU job and we will hand back a headroom assessment: its bottleneck classification (launch-bound, compute-bound, or serialized), the serialized-concurrency reading, the levers we tried, which ones passed the gate and which rolled back, and the verified before/after. You give us the workload and read access to run it; we give you a number that is a proven A/B or does not exist. Turnaround is days, not weeks, and it costs you nothing.
 
-----
-
 *Thanks to the OpenPCDet and OpenFold projects, and to the KITTI and nuScenes teams, whose open models and datasets made these benchmarks reproducible.*

Issue 1: Inconsistent table formatting
The table in the "Tracks 2 and 3" section has inconsistent spacing before the first | character, which can lead to rendering issues in some Markdown parsers.

--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -64,17 +64,17 @@
  Same lever on both: **frame batching**, collating several frames into one forward pass so per-frame launch cost is divided by the batch size. In eval mode the normalization is fixed and convolutions act per sample, so batching does not mix frames; it only shares the launches.
 -   
 | Batch | KITTI | nuScenes |   
-| --- | --- | --- |
-| 2 | +37.3% | kept baseline (not faster) |  
-| 4 | +63.2% | +7.1% |  
-| 8 | +174.9% | +23.2% |  
-| 16 | +275.4% | +34.0% |  
-| 32 | +366.0% | +37.3% |  
-| 64 | +427.0% | +25.8% |  
-| 128 | rolled back | +34.0% |  
-| 256+ | OOM | OOM |  
+| --- | --- | --- |
+| 2 | +37.3% | kept baseline (not faster) |
+| 4 | +63.2% | +7.1% |
+| 8 | +174.9% | +23.2% |
+| 16 | +275.4% | +34.0% |
+| 32 | +366.0% | +37.3% |
+| 64 | +427.0% | +25.8% |
+| 128 | rolled back | +34.0% |
+| 256+ | OOM | OOM |

Suggested change:
Remove the extra spaces before the first | in the table rows.

--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -64,17 +64,17 @@
  Same lever on both: **frame batching**, collating several frames into one forward pass so per-frame launch cost is divided by the batch size. In eval mode the normalization is fixed and convolutions act per sample, so batching does not mix frames; it only shares the launches.
 -   
 | Batch | KITTI | nuScenes |   
-| --- | --- | --- |
-| 2 | +37.3% | kept baseline (not faster) |  
-| 4 | +63.2% | +7.1% |  
-| 8 | +174.9% | +23.2% |  
-| 16 | +275.4% | +34.0% |  
-| 32 | +366.0% | +37.3% |  
-| 64 | +427.0% | +25.8% |  
-| 128 | rolled back | +34.0% |  
-| 256+ | OOM | OOM |  
+| --- | --- | --- |
+| 2 | +37.3% | kept baseline (not faster) |
+| 4 | +63.2% | +7.1% |
+| 8 | +174.9% | +23.2% |
+| 16 | +275.4% | +34.0% |
+| 32 | +366.0% | +37.3% |
+| 64 | +427.0% | +25.8% |
+| 128 | rolled back | +34.0% |
+| 256+ | OOM | OOM |

Issue 2: Generic code block syntax
The shell command blocks use generic markdown code block syntax (````) instead of specifying sh for shell commands. This is a minor stylistic point for better syntax highlighting.

--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -100,14 +100,14 @@
  Every result regenerates from a single command per track. The HFT A/B, on a dataset you regenerate deterministically from a seed:
 -```
  python -m gitm.benchmarks.hft.generate --events 1_000_000_000 --seed 42 --out ./hft_1b_seed42
  gitm run --workload hft --optimize --seed 42 --stage ./hft_1b_seed42 --max-events 400_000_000

The KITTI batch sweep:
-```
for B in 1 2 4 8 16 32 64 128 256 512; do
AB=$(( B < 64 ? 64 : B ))
GITM_EDGE_CKPT=.../pointpillar_7728.pth
GITM_EDGE_CFG=.../pointpillar.yaml
GITM_EDGE_FRAMES=$AB GITM_EDGE_AB_FRAMES=$AB GITM_EDGE_BATCH_SIZE=$B
python -m gitm.cli run --workload kitti --report kitti_b$B.md


**Suggested change:**
Specify `sh` for shell code blocks.

```diff
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -100,14 +100,14 @@
  Every result regenerates from a single command per track. The HFT A/B, on a dataset you regenerate deterministically from a seed:
 -```
  python -m gitm.benchmarks.hft.generate --events 1_000_000_000 --seed 42 --out ./hft_1b_seed42
  gitm run --workload hft --optimize --seed 42 --stage ./hft_1b_seed42 --max-events 400_000_000
- ```
+ ```sh
 -
  The KITTI batch sweep:
 -```
  for B in 1 2 4 8 16 32 64 128 256 512; do
    AB=$(( B < 64 ? 64 : B ))
    GITM_EDGE_CKPT=.../pointpillar_7728.pth \
    GITM_EDGE_CFG=.../pointpillar.yaml \
    GITM_EDGE_FRAMES=$AB GITM_EDGE_AB_FRAMES=$AB GITM_EDGE_BATCH_SIZE=$B \
    python -m gitm.cli run --workload kitti --report kitti_b$B.md

docs/autoresearch.md

Issue 1: Hyphenation inconsistency
The phrase "largest-residual op" is used in some places, but "largest residual op" is also used. Consistency would improve readability.

--- a/docs/autoresearch.md
+++ b/docs/autoresearch.md
@@ -46,7 +46,7 @@
 
 **Honest ceiling:** repointing changes what the search aims at, how proposals are
 prioritized, and the recorded "why" (`target_op` on every result). It does **not**
-by itself produce genuinely different knobs per op — the lever set still comes
+by itself produce genuinely different knobs per op — the lever set still comes
 from the class table. Real per-op lever affinity would need hand-authored
 op→knob mappings, which v0 does not claim.
 

Suggested change:
Standardize on "largest-residual op" for consistency.

--- a/docs/autoresearch.md
+++ b/docs/autoresearch.md
@@ -46,7 +46,7 @@
 
 **Honest ceiling:** repointing changes what the search aims at, how proposals are
 prioritized, and the recorded "why" (`target_op` on every result). It does **not**
-by itself produce genuinely different knobs per op — the lever set still comes
+by itself produce genuinely different knobs per op — the lever set still comes
 from the class table. Real per-op lever affinity would need hand-authored
 op→knob mappings, which v0 does not claim.
 

gitm/agents/autoresearch.py

Issue 1: Magic numbers for thresholds
The thresholds _SC_THRESHOLD and _MEMCPY_THRESHOLD are magic numbers. While their meaning is explained in comments, giving them more descriptive names or making them configurable would improve maintainability and clarity.

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -47,9 +47,9 @@
 #: Serialized-concurrency fraction above this ⇒ kernels ran back-to-back on one
 #: stream instead of overlapping: scheduling gaps / launch-bound idle time.
 _SC_THRESHOLD = 0.5
 #: memcpy share of GPU operations above this ⇒ data movement dominates.
 _MEMCPY_THRESHOLD = 0.25
 
 
 def classify_bottleneck(trace: Trace) -> str:

Suggested change:
Rename the thresholds to reflect their purpose more clearly, e.g., _SERIALIZED_CONCURRENCY_IDLE_THRESHOLD and _MEMCPY_FRACTION_MEMORY_BOUND_THRESHOLD.

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -47,9 +47,9 @@
 #: Serialized-concurrency fraction above this ⇒ kernels ran back-to-back on one
 #: stream instead of overlapping: scheduling gaps / launch-bound idle time.
 _SC_THRESHOLD = 0.5 # Consider renaming to _SERIALIZED_CONCURRENCY_IDLE_THRESHOLD
 #: memcpy share of GPU operations above this ⇒ data movement dominates.
 _MEMCPY_THRESHOLD = 0.25 # Consider renaming to _MEMCPY_FRACTION_MEMORY_BOUND_THRESHOLD
 
 
 def classify_bottleneck(trace: Trace) -> str:

Issue 2: Accessing private argparse attribute
The _argparse_domains function accesses the private _actions attribute of argparse.ArgumentParser. While this might be necessary for introspection of a third-party library, it's a fragile dependency that could break if argparse internals change. A comment acknowledging this would be beneficial.

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -349,7 +349,7 @@
         parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())  # type: ignore[attr-defined]
     except Exception:
         return {}
     out: dict[str, _ArgDomain] = {}
     for action in getattr(parser, "_actions", []):
         dest = getattr(action, "dest", "")
         if not dest or dest == "help":

Suggested change:
Add a comment explaining the use of the private attribute.

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -349,7 +349,7 @@
         parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())  # type: ignore[attr-defined]
     except Exception:
         return {}
     out: dict[str, _ArgDomain] = {}
-    for action in getattr(parser, "_actions", []):
+    for action in getattr(parser, "_actions", []): # Accessing private _actions for introspection
         dest = getattr(action, "dest", "")
         if not dest or dest == "help":
             continue

tests/test_autoresearch.py

Issue 1: Local import of typing.Literal
The typing.Literal import is placed inside the test_field_kind_and_choices_extracts_literal_enum function. It's generally better practice to place imports at the top of the file.

--- a/tests/test_autoresearch.py
+++ b/tests/test_autoresearch.py
@@ -360,7 +360,7 @@
 
 
 def test_field_kind_and_choices_extracts_literal_enum() -> None:
-    import typing
+    import typing # Move to top of file
 
     kind, choices = _field_kind_and_choices(typing.Literal["recompute", "swap"])
     assert kind == "enum"

Suggested change:
Move import typing to the top of the file.

--- a/tests/test_autoresearch.py
+++ b/tests/test_autoresearch.py
@@ -10,6 +10,7 @@
 from gitm.kernels.library import load_library
 from gitm.kernels.spec import InterventionSpec, SafetyGate
 from gitm.optimizer.apply import DictApplicator
+import typing # Moved from test_field_kind_and_choices_extracts_literal_enum
 from gitm.optimizer.monitor import KernelResidual, Residuals
 from gitm.tracer.schema import Trace
 
@@ -360,7 +361,7 @@
 
 
 def test_field_kind_and_choices_extracts_literal_enum() -> None:
-    import typing
+    
 
     kind, choices = _field_kind_and_choices(typing.Literal["recompute", "swap"])
     assert kind == "enum"

Trimmed multi-paragraph docstrings/comments added across the recent
commits (residual attribution, prerequisite veto, joint candidates,
coverage unification, relative catalog values) down to what a reader
actually needs, in apply.py, deviation.py, monitor.py, replay.py,
vllm_knobs.py, spec.py, and loop.py. No behavior change.
…e from autoresearch's static table

Three new curated library.yaml entries, all backed by real evidence:

- async_scheduling: measured +1.7% (kept) on an actual L4/opt-125m
  trace via autoresearch's generative search. Overlaps CPU-side
  scheduling with GPU execution, directly targeting idle_stall
  (launch-bound gaps). Low risk, no numerics change.
- cpu_offload_gb / preemption_mode=swap: promoted from autoresearch's
  static _RULES fallback table (memory_bound) to reviewed catalog
  entries with real applicability/safety/source metadata, instead of
  living only as a bare (knob, value, rationale) tuple.

autoresearch proposes *outside* the catalog by design — cpu_offload_gb
and preemption_mode are removed from _RULES["memory_bound"] (now empty,
same as idle_stall already was) so the disjoint-from-catalog invariant
(test_proposed_knobs_are_disjoint_from_catalog) holds. This also empties
the offline/no-vLLM fallback's memory_bound coverage, since both of its
only two candidates graduated — same gap idle_stall's fallback already
had. Updated 6 tests that exercised the old memory_bound fallback path
to use compute_bound (still has compilation_config) or to assert the
now-honest empty result, rather than paper over the gap.

Also compresses the comments/docstrings touched in autoresearch.py
along the way.
A markdown table cell had a raw Windows-1252 em-dash byte (0x97)
instead of a proper UTF-8-encoded em-dash — every other em-dash in the
file was already correctly encoded (0xE2 0x80 0x94), just this one
stray byte from what was likely a copy-paste. Both the gemini-review
and review CI checks read the full PR diff as UTF-8 and crashed with
UnicodeDecodeError on this exact byte, which is why they were failing
-- not any of the code-quality findings those bots had posted.
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce a new autoresearch agent, dynamic knob value resolution, and improved engine lifecycle management for vLLM workloads. The new features are well-integrated with the existing policy and rollback mechanisms, and the documentation and tests cover the new functionality comprehensively.

One minor issue was identified:

Issue: Duplicate Test Function

The test function test_report_kernel_time_residual_uses_weighted_total_and_clamps is present in two different test files: tests/test_vllm_knobs_and_restart.py and tests/test_run_loop_workload.py. This leads to redundant testing and makes maintenance harder. The test is more appropriately placed in tests/test_run_loop_workload.py as it validates the _agg_kt_residual function, which is part of gitm/scheduler/loop.py.

File: tests/test_vllm_knobs_and_restart.py

--- a/tests/test_vllm_knobs_and_restart.py
+++ b/tests/test_vllm_knobs_and_restart.py
@@ -242,26 +422,6 @@
     assert out["summary"]["scheduler_stats"] is not None
 
 def test_report_kernel_time_residual_uses_weighted_total_and_clamps():
     from gitm.optimizer.monitor import KernelResidual, Residuals
     from gitm.scheduler.loop import _agg_kt_residual
 
     res = Residuals(
         per_kernel=[
             KernelResidual(op="tiny", layer=None, r_kt=9999.0, r_mt=None, t_obs_s=200e-6, t_pred_s=1e-8),
             KernelResidual(op="main", layer=None, r_kt=0.1, r_mt=None, t_obs_s=11e-6, t_pred_s=10e-6),
         ]
     )
 
     assert _agg_kt_residual(res) == 1.0
 
     sane = Residuals(
         per_kernel=[
             KernelResidual(op="a", layer=None, r_kt=9.0, r_mt=None, t_obs_s=12e-6, t_pred_s=10e-6),
             KernelResidual(op="b", layer=None, r_kt=0.2, r_mt=None, t_obs_s=12e-6, t_pred_s=10e-6),
         ]
     )
     assert _agg_kt_residual(sane) == pytest.approx(0.2)
 
 
 def test_ar_target_residual_uses_the_search_target_not_a_hardcoded_zero():
     from gitm.agents.autoresearch import AutoresearchRun, ResidualTarget
     from gitm.scheduler.loop import _ar_target_residual
 
     # No target found (nothing exceeded its predicted ceiling) -> honest 0.0.
     empty = AutoresearchRun(bottleneck_class="idle_stall", results=[], target=None)
     assert _ar_target_residual(empty) == 0.0
 
     # A real target -> its residual surfaces, clamped like every other residual.
     modest = AutoresearchRun(
         bottleneck_class="idle_stall", results=[],
         target=ResidualTarget(op="attn_score_value", residual=0.42, n_kernels=8),
     )
     assert _ar_target_residual(modest) == pytest.approx(0.42)
 
     huge = AutoresearchRun(
         bottleneck_class="idle_stall", results=[],
         target=ResidualTarget(op="attn_score_value", residual=17.8, n_kernels=8),
     )
     assert _ar_target_residual(huge) == 1.0

Suggested change:
Remove the test_report_kernel_time_residual_uses_weighted_total_and_clamps function from tests/test_vllm_knobs_and_restart.py. The version in tests/test_run_loop_workload.py should be kept.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid with zero default produces incorrect grid

base = d if isinstance(d, int | float) and d not in (0, False) else 1

For swap_space the default is 0 (common), so base=1 and the grid becomes [0.5, 2.0, 4.0] — float values for what should be an integer knob (GiB). The int rounding saves it, but round(0.5)0 which is then filtered by >= 1, leaving [2, 4]. The library.yaml comment acknowledges this ("0 * any multiplier stays 0") but the code silently substitutes base=1 without surfacing that the absolute values now have no relationship to the current config. This should at minimum log a warning.

largest_residual uses max() on a dict that could yield a non-positive winner

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))
mean = sum(values) / len(values)
if mean <= 0:
    return None

max() is called unconditionally, computing the winner before checking if it's positive. If all ops have mean ≤ 0, you still do the max() scan unnecessarily. Minor, but the early-exit pattern would be cleaner and avoids misleading intent.

_roofline_memory_fraction uses mem_score scaling with _MEMCPY_THRESHOLD

mem_score = max(mem_score, roofline_frac / _MEMCPY_THRESHOLD)

roofline_frac is already a fraction [0,1] of total kernel time that is memory-bound — normalising it by _MEMCPY_THRESHOLD=0.25 means a roofline prediction of 50% memory-bound yields mem_score=2.0, while the same 50% from memcpy fraction yields 0.5/0.25=2.0. These happen to be consistent, but the semantics are different (one is predicted bound, the other is copy ops) and the comment in the doc doesn't flag that the roofline path effectively makes _MEMCPY_THRESHOLD serve double duty as a normaliser for a conceptually different signal.

library.yaml value_multiplier_grid is a new field with no parsing code in the diff
The YAML adds value_multiplier_grid and value_max fields but no Python loader changes are shown. If load_library() ignores unknown keys, these fields are silently dropped and the catalog behaves as if only the hardcoded value applies — the whole "dynamic sweep" goal is lost without error.

_joint_prerequisite_candidatesmax_candidates cap is not applied to extra candidates
In GenerativeProposer.propose:

out += self._extra_candidates(...)
return out if self._max is None else out[:self._max]

The cap is applied after appending joint candidates, so the effective cap for the main grid candidates is max_candidates - len(joint_candidates). With max_candidates=24 and several joint pairs this could crowd out the primary grid candidates entirely, with no visibility into why counts are low.


🔒 Security

_argparse_domains calls engine_args_cls.add_cli_args(argparse.ArgumentParser()) on an arbitrary class passed in. If _knobs_from_engine_args is ever called with an untrusted object (e.g., from a YAML-loaded config), this is arbitrary method invocation. Currently only called with EngineArgs, but the function signature accepts object.


⚡ Performance

_ProposerBase._searchable() calls self._source.knobs() (and load_library() in __init__) on every propose() call via _searchable(). The knob list and catalog are stable within a run — both should be cached (e.g., functools.cached_property or memoised on first call).

_op_present iterates all trace kernels and calls classify_op per kernel on each autoresearch_v0 invocation. For large traces this is repeated linear work; the result should be memoised or the set of present ops pre-computed once per trace.


📊 Reproducibility

StochasticProposer seed is a constructor parameter defaulting to 0, which is correct, but autoresearch() and autoresearch_v0() accept a proposer and don't document what seed was used. The AutoresearchRun output has no seed field, so the run is not reproducible from the saved autoresearch.json alone — you'd need to know the constructor args too.

_visible_gpu_count() calls torch.cuda.device_count() at proposal time, which can vary between runs if GPUs are added/removed from the process's CUDA_VISIBLE_DEVICES. The count should be captured once at loop startup and passed explicitly, which VLLMKnobSource(gpu_count=...) supports but isn't enforced.


💡 Suggestions

_RULES is entirely empty for idle_stall and memory_bound (both moved to library.yaml). FallbackProposer falls back to TableProposer when the primary returns nothing, but TableProposer delegates to propose() which reads _RULES — so for idle_stall the fallback also returns nothing. The fallback chain silently produces zero candidates for two of three classes. At minimum this deserves a warning log.

apply_error on AutoresearchResult is populated as applied.error but no error field is visible on the return type of apply_intervention in this diff. If the field name ever changes on the apply result, this silently becomes None for all errors rather than raising an AttributeError — consider getattr(applied, "error", None) with an explicit comment, or a dedicated accessor.

gpu_memory_utilization_dynamic uses value_multiplier_grid: [0.95, 1.03, 1.10] with value_max: 0.97. 1.03 × 0.92 = 0.948 and 1.10 × 0.92 = 1.012 — the 1.10 multiplier exceeds 1.0 for the fallback value 0.92, making value_max: 0.97 load-bearing for correctness. But again, there's no parsing code in the diff to enforce it.

FallbackProposer is not a @dataclass or Protocol subclass — it holds mutable state (_primary, _secondary) but has no __repr__, making it opaque in logs. The other proposers have the same issue; adding __repr__ to the base would help debuggability in the autoresearch report.

… them out

GenerativeProposer.propose() appended joint (prerequisite+dependent)
candidates after the per-knob value grid, then sliced the combined
list to max_candidates from the front. A large single-knob grid (the
common case — real EngineArgs surfaces have many affine knobs) could
fill the whole cap on its own, silently truncating every joint
candidate off the end with no visibility into why.

Put joint candidates first instead: they're deliberately targeted
(unlocking a prerequisite-gated knob atomically), so they shouldn't
lose a slot to the more generic grid search. Adds a regression test
with a 50-knob filler surface confirming a joint candidate survives a
max_candidates=10 cap that the filler grid alone would otherwise fill.
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/agents/autoresearch.py
+++ b/gitm/agents/autoresearch.py
@@ -709,7 +709,7 @@
         if prereq is None or prereq.kind != "bool":
             continue
         for knob in knobs:
-            if knob.name == prereq_name or needle not in knob.name.lower():
+            if knob.name == prereq_name or needle not in knob.name.lower(): # Bug: This condition should be `if needle in knob.name.lower()` to match dependent knobs.
                 continue
             if not _affine(knob, bottleneck_class, keywords):
                 continue

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Code Review

🐛 Bugs

_value_grid integer rounding edge case (autoresearch.py:~430):

vals = sorted({int(round(x)) for x in raw if round(x) >= 1})

round(x) >= 1 is checked on the float but int(round(x)) is computed separately — these can disagree on borderline floats. Use a single expression: {v for x in raw if (v := int(round(x))) >= 1}.

largest_residual calls max() on potentially empty dict (autoresearch.py:~210):

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

The if not res.per_kernel guard above protects against a completely empty list, but by_op will always have at least one entry if per_kernel is non-empty, so this is fine as written. However, the max is computed unconditionally before the mean <= 0 check — this means the function always does O(N) work and finds the max even when the entire result will be discarded. Minor, but worth noting.

roofline_frac / _MEMCPY_THRESHOLD is a category error (autoresearch.py:~175):

mem_score = max(mem_score, roofline_frac / _MEMCPY_THRESHOLD)

roofline_frac is a fraction of total kernel time that is memory-bound (0–1), while _MEMCPY_THRESHOLD = 0.25 was calibrated for the memcpy share of GPU ops. Dividing a roofline fraction by a memcpy threshold mixes two different scales — a roofline fraction of 0.25 yields mem_score = 1.0 which is the same as having 25% memcpy ops, but these are not equivalent signals. The thresholds need to be independent.

swap_space multiplier collapse is silently wrong (library.yaml:~112):

value: 4  # fallback; also the outcome when current swap_space is 0 (common
  # default) — 0 * any multiplier stays 0, so the sweep collapses to this.

The comment acknowledges 0 * multiplier = 0 but the fix (fall back to the static value) isn't in the schema definition — it's just documented. If the consumer reads value_multiplier_grid and blindly scales a live swap_space=0, it gets [0.0, 0.0, 0.0] and silently proposes nothing useful. This needs a code-level guard, not just a comment.

gpu_memory_utilization multiplier grid can exceed 1.0 (library.yaml:~140):

value_multiplier_grid: [0.95, 1.03, 1.10]
value_max: 0.97

1.03 × 0.92 = 0.9476 and 1.10 × 0.92 = 1.012 — the last point exceeds 1.0 on a typical default. value_max: 0.97 presumably clamps this, but there's no evidence that value_max is enforced anywhere in the code; InterventionSpec has no such field and nothing in autoresearch.py reads it. This is a schema field with no consumer.

🔒 Security

_argparse_domains calls engine_args_cls.add_cli_args() on an arbitrary object (autoresearch.py:~530):

parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())

_knobs_from_engine_args accepts engine_args_cls: object with no validation. If a caller passes a hostile or unexpected object, this executes its add_cli_args method. The type should be narrowed with a runtime hasattr check before calling.

⚡ Performance

load_library() called per-proposer-instantiation (autoresearch.py:~590):

self._catalog = (
    set(catalog_knobs)
    if catalog_knobs is not None
    else {s.knob for s in load_library()}
)

If multiple proposers are instantiated (e.g. FallbackProposer(EngineArgsProposer(), TableProposer())), load_library() is called twice, each time presumably hitting disk. Cache at module level or accept it as a parameter from above.

_searchable() re-enumerates and re-filters the full knob list on every propose() call (autoresearch.py:~610). For EngineArgsProposer this includes _argparse_domains() which creates a full ArgumentParser. The result is deterministic — it should be computed once and cached (e.g. functools.cached_property).

📊 Reproducibility

StochasticProposer seed is an instance parameter but Random is re-created per propose() call (autoresearch.py:~790):

rng = random.Random(self._seed)

Re-seeding on every call means two consecutive calls with the same bottleneck_class produce identical output. This is probably intentional for per-call reproducibility, but it means a loop that calls propose() repeatedly will never explore new points without an external seed change. The docs say "varied by changing it" — but nothing in the loop increments the seed. Consider storing the Random instance as self._rng (seeded once at init) so successive calls do explore.

_FALLBACK_KNOBS used both as an offline fallback and as a test fixture — if the fallback list drifts from real vLLM args, offline tests will pass while live proposals are wrong. There is no test asserting the fallback knobs are a subset of the live introspected surface.

💡 Suggestions

value_multiplier_grid and value_max in library.yaml are schema extensions with no parser support. InterventionSpec / load_library() presumably ignores unknown fields. These will silently do nothing unless the loader and apply_intervention are updated. Add a schema validation step or a failing test that asserts these fields are consumed.

_RULES tables for idle_stall and memory_bound are emptyFallbackProposer will fall through to TableProposer which returns [] for those classes, meaning the fallback is genuinely empty for 2 of 3 classes. The comment explains why (cpu_offload_gb/preemption_mode moved to library.yaml), but the net effect is that TableProposer is only useful for compute_bound. Consider removing it from FallbackProposer for those classes or documenting explicitly that the fallback is intentionally empty.

autoresearch_v0 sets applicable=applied is not None (autoresearch.py:~880), but applied is None both for gate-rejected candidates and for candidates with a caller-veto (reject). These two cases have meaningfully different semantics and are currently indistinguishable in the result without reading rejected_reason. A separate vetoed: bool field would improve report clarity.

No timeout on apply_intervention — the apply_error field captures exceptions, but a hung engine build will block the loop indefinitely. The autoresearch_v0 loop has no per-candidate deadline.

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

CI status update

All checks are now green: gemini-review, review, lint, and pytest (3.10/3.11/3.12) all pass as of 4daa319.

Root cause of the CI failures: both gemini-review and review were crashing on a UnicodeDecodeError — a single raw Windows-1252 em-dash byte (0x97) in a markdown table cell in docs/autoresearch.md, instead of a proper UTF-8-encoded em-dash (every other em-dash in that file was already correct). Both bots read the full PR diff as UTF-8 and crashed before producing any review output. Fixed in 491ee00.

On the flood of prior bot comments: the ~30 comments accumulated on this PR are largely stale, duplicated across many re-runs, or false when checked against the current code. A few examples verified while triaging:

  • Several claimed knob_kind isn't imported in loop.py (with 3+ different, mutually contradictory suggested import paths) — it's correctly imported from gitm.optimizer.vllm_knobs.
  • Claims that value_multiplier_grid/value_max in library.yaml have "no parser support" and "silently do nothing" — false; both are real InterventionSpec fields consumed by resolve_relative_value/expand_relative_candidates, covered by tests.
  • A claimed bug in _joint_prerequisite_candidates's needle-matching continue condition — the suggested "fix" describes the same logic already in place (misread double-negative), confirmed by the passing test_joint_prerequisite_candidates.
  • _op_present's substring-match bug, flagged repeatedly — already fixed earlier in this branch's history (now uses classify_op identity matching).

One real, actionable finding did survive triage (fixed in 4daa319, prior to this comment): GenerativeProposer.propose() appended joint (prerequisite+dependent) candidates after the per-knob value grid, then sliced to max_candidates from the front — so a large single-knob grid could silently crowd every joint candidate off the list. Fixed by putting joint candidates first; added a regression test with a 50-knob filler surface proving a joint candidate survives a tight cap.

No inline/resolvable review threads exist on this PR (verified via gh api repos/GitM-Labs/runtime/pulls/56/comments, consistently 0) — all bot feedback has landed as plain issue comments, so there's nothing to reply-to-and-resolve in GitHub's review-thread sense.

🤖 Addressed by Claude Code

main (#60) added run.workload_id = "vllm-decode" (read by loop.py to
infer the workload id off the engine) and a regression test guarding
the factory's return path. This branch had since added
gitm_baseline_restart_fn (used by LiveEngineApplicator's
restart_mode="serial") in the same wiring block. Kept both.

The merged-in regression test (test_vllm_decode_factory_returns_wired_
runner) called gitm_restart_fn with the old 3-arg (engine, knob, value)
signature; updated to the dict-based (engine, {knob: value}) signature
this branch introduced for joint candidates.

benchmarks/README.md and tests/test_run_loop_workload.py's other
additions merged cleanly (no conflicts, purely additive).
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

```diff
--- a/gitm/optimizer/apply.py
+++ b/gitm/optimizer/apply.py
@@ -76,14 +77,14 @@ def apply_intervention(
     try:
         applicator.apply(spec)
     except Exception as exc:
         applicator.restore(snapshot)
-        _audit(audit, "revert", spec, cause=f"apply failed, restored: {exc}")
+        _audit(audit, "revert", spec, cause=f"apply failed, restored: {exc}", knobs=_knob_values(spec))
         return ApplyResult(False, rolled_back=True, measured_delta=None,
                            error=f"apply failed, restored: {exc}")
     _audit(audit, "apply", spec, cause="applied live", knobs=_knob_values(spec))
 
     # Step 3: measure. A crash mid-measurement also rolls back.
     try:
         delta = applicator.measure(spec)
     except Exception as exc:
         applicator.restore(snapshot)
-        _audit(audit, "revert", spec, cause=f"measure failed, restored: {exc}")
+        _audit(audit, "revert", spec, cause=f"measure failed, restored: {exc}", knobs=_knob_values(spec))
         return ApplyResult(False, rolled_back=True, measured_delta=None,
                            error=f"measure failed, restored: {exc}")

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid when default is 0 uses base=1 silently

base = d if isinstance(d, int | float) and d not in (0, False) else 1

For swap_space (default=0), base=1 gives grid [0, 2, 4] (after rounding/filtering), not values relative to the actual default. The YAML comment acknowledges 0 * multiplier = 0 but the code path doesn't — it silently substitutes 1 as the base, producing arbitrary values (0.5, 2.0, 4.0) that have no relationship to the live engine's current setting. The comment and the code tell different stories.

largest_residual uses max() before checking positivity

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))
mean = sum(values) / len(values)
if mean <= 0:
    return None

max is called unconditionally — if all means are negative it still picks the least-negative op, computes mean, then returns None. That's correct in outcome but wasteful and subtly wrong if by_op is empty (it isn't because of the early guard, but it's fragile). More importantly, it means values is recomputed from the list returned by max, which is fine here, but the double-traversal is unnecessary.

_affinity_strength returns 1 for any Knob.classes tag regardless of match count

if knob.classes:
    return 1

But _delta_mean_for(strength) multiplies by _DELTA_MEAN_PER_MATCH. A knob with 3 explicit class tags gets the same delta_mean as one with 1, while a keyword-matched knob with 3 hits gets 3×. The explicit tag path (supposedly higher confidence) is systematically under-scored vs. keyword matching.

StochasticProposer: if not any(weights) is always False when epsilon > 0

weights = [
    1.0 if _affine(...) else self._epsilon
    for k, _grid in eligible
]
if not any(weights):
    return []

With epsilon=0.15 (the default), every weight is ≥ 0.15, so any(weights) is always True. The guard only fires for epsilon=0 with no affine knobs. This is documented correctly but the guard doesn't protect the epsilon=0 + empty-affine case correctly either — rng.choices with all-zero weights raises ValueError.

value_multiplier_grid and value_max fields in library.yaml have no consuming code in this diff
The YAML now has value_multiplier_grid: *sweep_grid and value_max: 0.97 on several entries, but no code here reads these fields. Either the schema/loader is updated elsewhere (not shown) or these are silently ignored, meaning the "dynamic sweep" described in summaries doesn't actually happen.


🔒 Security

_argparse_domains calls engine_args_cls.add_cli_args(argparse.ArgumentParser()) on an untrusted class object if KnobSource is ever pointed at a non-vLLM class. Low severity here since the call site controls the class, but worth noting if KnobSource becomes pluggable via config.


⚡ Performance

_searchable() calls load_library() on every propose() call

self._catalog = ... {s.knob for s in load_library()}

load_library() is called once at __init__ time, which is fine — but _searchable() iterates self._source.knobs() on every call too, and VLLMKnobSource.knobs() calls _engine_arg_knobs() which either imports vLLM or walks dataclasses.fields + _argparse_domains (builds a full argparse parser) every time. The result should be cached on first call.

StochasticProposer builds eligible + weights lists on every propose() call even though the knob surface doesn't change between calls of the same instance.


📊 Reproducibility

StochasticProposer seed is an instance attribute, not reset between propose() calls

rng = random.Random(self._seed)

Each propose() call reconstructs rng from the same seed, so two calls with the same bottleneck_class return the same candidates. This is probably intentional ("reproducible for a given seed") but means the proposer can't explore different candidates across loop iterations without externally changing the seed. The docs note this but the loop integration doesn't show seed rotation.

classify_bottleneck result depends on roofline_frac which depends on residuals.per_kernel[i].bound — if bound assignment upstream is non-deterministic (e.g. float tie-breaking in the roofline model), classification can vary run-to-run on the same trace, silently changing which proposals are generated.


💡 Suggestions

_joint_prerequisite_candidates silently produces no candidates when _FALLBACK_KNOBS is active (offline mode). This is documented but the caller (EngineArgsProposer._extra_candidates) gets an empty list with no indication of why, making offline test coverage of the joint-candidate path impossible without mocking _searchable().

autoresearch_v0 catches no exception from apply_intervention — if apply_intervention raises (rather than returning a result with .error set), the whole pass fails. The apply_error field on AutoresearchResult implies errors are expected to be captured, but only applied.error (a field on the return value) is surfaced, not exceptions.

gpu_memory_utilization_dynamic uses value_multiplier_grid: [0.95, 1.03, 1.10] with value_max: 0.97 — the grid contains values (1.03, 1.10) that are multipliers, not absolute values, but the field name value_max implies an absolute cap. If the consuming code interprets these as absolute values rather than multipliers, 1.03 × 0.92 = 0.948 > 0.97 would be clipped, but 1.10 × 0.92 = 1.012 would be clipped to 0.97 — the intent isn't self-evident from the field names alone and the schema should be documented explicitly.

Missing __eq__/__hash__ on AutoresearchResult makes deduplication in tests and result aggregation rely on object identity, which is error-prone when comparing runs.

All three apply_intervention() "revert" audit calls (apply-raises,
measure-raises, regression rollback) omitted knobs=_knob_values(spec),
so a rollback's audit entry recorded only the cause string, not which
knob(s) were involved — the sibling "apply" entry already had this.
Fixed all three call sites and extended test_apply_rollback.py to
assert the knobs detail on every revert path, including a new test
covering the apply-raises/measure-raises cases directly.

autoresearch: explicit Knob.classes tag scores max, not flat 1

_affinity_strength() gave an explicit, authored Knob.classes tag a
flat score of 1, while a knob matched by several affinity keywords
incidentally could score higher (e.g. 2+) — inverting the intended
confidence ordering. An authored tag is ground truth and must never
be outranked by a coincidental multi-keyword name match, so it now
scores max(len(keywords), 1), the highest score reachable for that
call. Updated the corresponding assertion in test_autoresearch.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

Addressed this round's review feedback (commit 574f5cd):

Real, fixed:

  • apply.py: all three _audit(audit, "revert", spec, ...) calls were missing knobs=_knob_values(spec), unlike the sibling "apply" call — a rollback's audit entry recorded the cause but not which knob(s) were involved. Fixed all three call sites; extended test_apply_rollback.py with an explicit assertion on the existing regression test plus a new test covering the apply-raises/measure-raises paths directly.
  • autoresearch.py: _affinity_strength() gave an explicit, authored Knob.classes tag a flat score of 1, while a knob matched by several keywords incidentally could score higher — inverting the intended confidence ordering (an authored tag should never be outranked by a coincidental multi-keyword name match). Now scores max(len(keywords), 1), the ceiling for that call. Updated the corresponding assertion in test_autoresearch.py.

Verified false / already addressed, no action taken:

  • value_multiplier_grid/value_max "no consuming code" — false; both are declared on InterventionSpec and fully implemented by resolve_relative_value/expand_relative_candidates in vllm_knobs.py, with test coverage.
  • largest_residual max()-before-guard pattern — already assessed correct in an earlier round.
  • StochasticProposer weight guard — already assessed correct in an earlier round.
  • Remaining caching/perf suggestions in the batch — nice-to-haves, not bugs; intentionally not acted on.

No real inline/resolvable PR review threads exist (gh api .../pulls/56/comments still returns []) — all bot feedback continues to land as plain issue comments, so this is posted as one consolidated summary per the established pattern.

Full test run (test_autoresearch.py, test_apply_rollback.py, test_vllm_knobs_and_restart.py, test_run_loop_workload.py, test_vllm_embodiment.py, test_catalog_unify.py) is green.

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Code Review

🐛 Bugs

_value_grid with zero default collapses to base=1, producing wrong grid

base = d if isinstance(d, int | float) and d not in (0, False) else 1

If swap_space=0 (the YAML even acknowledges "0 * any multiplier stays 0"), the code silently substitutes base=1, generating grid [0, 1, 2, 4] instead of emitting nothing or using the explicit value. This is a logic error — a knob with a zero default has no meaningful multiplicative grid, and the workaround (explicit value in YAML) is only effective for TableProposer, not GenerativeProposer.

_roofline_memory_fraction normalizes incorrectly

mem_score = max(mem_score, roofline_frac / _MEMCPY_THRESHOLD)

roofline_frac is already a fraction (0–1) expressing the proportion of memory-bound kernel time. Dividing by _MEMCPY_THRESHOLD=0.25 means a roofline result of 0.5 (50% memory-bound) produces mem_score=2.0. These two signals have completely different semantics and shouldn't share a threshold. The roofline fraction likely warrants its own threshold.

largest_residual calls max() on an empty iterator when all ops filter out

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

by_op is always non-empty when res.per_kernel is non-empty, so this doesn't crash — but the mean <= 0 check only guards the winner. If you ever pre-filter by_op (e.g. future code excludes certain ops), this will raise silently.

applies_to_kernels: in YAML (bare null)

applies_to_kernels: 

This deserializes as None in most YAML parsers, not []. Any downstream code doing for k in spec.applies_to_kernels will raise TypeError. Should be applies_to_kernels: [].

gpu_memory_utilization_dynamic multiplier grid can exceed 1.0

value_multiplier_grid: [0.95, 1.03, 1.10]
value_max: 0.97

value_max is declared but there's no code in _value_grid that reads or enforces it — this field appears to be aspirational YAML with no implementation. A baseline of 0.92 * 1.10 = 1.012 would be proposed without clamping.


🔒 Security

_argparse_domains calls engine_args_cls.add_cli_args on an arbitrary object

parser = engine_args_cls.add_cli_args(argparse.ArgumentParser())

If KnobSource or _knobs_from_engine_args is ever called with an attacker-controlled class (e.g. via a plugin/config path), this executes arbitrary class methods. Low severity given the current call sites, but worth noting as the KnobSource protocol is explicitly designed for extension.


⚡ Performance

_searchable() re-invokes knob_source.knobs() and refilters on every propose() call
Both _extra_candidates (via self._searchable()) and the main propose() loop (also via self._searchable()) call through independently. For VLLMKnobSource, this means two full _knobs_from_engine_args executions per propose() call, including two _argparse_domains parses. Cache the result at construction time or memoize it.

StochasticProposer rejection-sampling loop is unbounded in the degenerate case

max_attempts = max(self._n * 4, len(eligible) * 4)

When n_samples is large relative to len(eligible) * len(grid) (the actual unique pairs), this loop runs to max_attempts emitting fewer than n_samples results. This is correct but silently degraded — no warning, no early exit when seen exhausts the space.


📊 Reproducibility

StochasticProposer seed is instance-level but not run-level

rng = random.Random(self._seed)

The RNG resets to the same seed on every propose() call. If the proposer is reused across loop iterations (e.g. in the 24-hour loop), every iteration draws identical samples regardless of prior results. This is reproducible but not varied — the stated goal ("varied by changing it") requires the caller to pass a different seed each run, which is easy to forget. Consider seeding with (self._seed, run_id) or advancing the RNG state across calls.

classify_bottleneck is non-deterministic when sc_score == mem_score

return IDLE_STALL if sc_score >= mem_score else MEMORY_BOUND

Ties always resolve to IDLE_STALL. This is deterministic but undocumented, and the tie condition is reachable (e.g. both scores exactly equal 1.0). Worth a comment.


💡 Suggestions

value_multiplier_grid and value_max in YAML have no consuming implementation
The YAML schema now declares fields that InterventionSpec/load_library() presumably don't parse. Either the loader silently ignores them (dead config) or it raises on unknown keys. Either way, the "sweep relative to live engine value" feature described in the comments doesn't exist yet — the value: 8192 fallback is always what gets used. This should be flagged clearly as unimplemented rather than appearing in the YAML as if functional.

_RULES is effectively empty but the fallback table is still advertised

"idle_stall": [],
"memory_bound": [],

TableProposer and FallbackProposer will silently return nothing for idle_stall and memory_bound, falling through to zero candidates. The docs describe cpu_offload_gb and preemption_mode as catalog entries now, but a user relying on TableProposer as the fallback for memory_bound gets nothing. Consider at minimum a log warning.

autoresearch_v0's reject callback is applied after gate ranking but before apply
A spec rejected by reject still appears in results with applicable=False. However, predicted_delta was computed and used in ranking before the veto — a high-predicted-delta spec that reject vetoes still consumed a rank slot, potentially displacing a lower-ranked but actually applicable candidate. If reject is a hard structural constraint (not a soft preference), it should participate in select_interventions rather than being post-hoc.

Missing __eq__/__hash__ on AutoresearchResult makes deduplication fragile
Results are collected in a plain list with no deduplication. If propose() is called twice (e.g. via FallbackProposer when primary partially overlaps secondary), duplicate specs silently enter the results list and are applied twice.

_value_grid() substituted base=1 for an int/float knob whose real
default is 0 (e.g. cpu_offload_gb), fabricating a grid like [0, 1, 2, 4]
that doesn't represent a real multiplicative search around the knob's
actual value. A zero default has no meaningful ratio-based grid, so it
now returns [] instead of inventing one. Updated the test fixtures that
relied on the old fabricated-grid behavior (cpu_offload_gb -> nonzero
default) since they were exercising the value-grid path, not testing
zero specifically.

_searchable() re-ran self._source.knobs() (a fresh knob-surface parse,
e.g. VLLMKnobSource's argparse introspection) on every call; propose()
calls it twice per invocation (main loop + _extra_candidates), doubling
that work for no reason since source/catalog are fixed for the
proposer's lifetime. Now memoized per instance.

Also: one-line comment documenting that classify_bottleneck's sc/mem
tie resolves to idle_stall (was previously undocumented).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce an autoresearch agent that proposes non-catalog vLLM optimization candidates. This involves bottleneck classification, targeting the largest residual, generating candidates from knob surfaces, and integrating this into the existing intervention loop.

Here's a review of the changes:

docs/autoresearch.md

  • Issue: The documentation states that cpu_offload_gb and preemption_mode were moved to library.yaml, implying they are no longer in the _RULES table for autoresearch. However, the _RULES table in autoresearch.py for memory_bound is indeed empty, which is consistent. The documentation is accurate.

gitm/agents/autoresearch.py

  • Issue 1: The _FALLBACK_KNOBS tuple (lines 300-303) still contains cpu_offload_gb and preemption_mode. The documentation and the _RULES table explicitly state these have moved to library.yaml and autoresearch should only propose non-catalog levers. While _engine_arg_knobs is designed to filter out catalog knobs, _FALLBACK_KNOBS is used when vLLM cannot be imported, bypassing this filter. This means if vLLM is not importable, autoresearch might propose knobs that are already in the curated catalog, which contradicts its stated purpose.

    Suggested change:

    --- a/gitm/agents/autoresearch.py
    +++ b/gitm/agents/autoresearch.py
    @@ -297,9 +297,7 @@
     # GPU stack) so the generative path still runs offline and deterministically.
     _FALLBACK_KNOBS: tuple[Knob, ...] = (
         Knob("cpu_offload_gb", "int", default=0),
         Knob("preemption_mode", "enum", default="recompute", choices=("recompute", "swap")),
         Knob("compilation_config", "int", default=0, grid=(2, 3)),
     )

    Remove Knob("cpu_offload_gb", "int", default=0) and Knob("preemption_mode", "enum", default="recompute", choices=("recompute", "swap")) from _FALLBACK_KNOBS.

  • Issue 2: In _affinity_strength (lines 330-337), if knob.classes is present, it returns max(len(keywords), 1). If keywords is an empty tuple (e.g., for a class with no defined keywords), len(keywords) would be 0, making max(0, 1) return 1. This seems fine, but it's worth noting that if knob.classes is present, the keywords parameter might not be relevant for that specific knob's affinity calculation. The current logic ensures a minimum strength of 1, which is reasonable.

    No change needed, but the behavior is noted.

  • Issue 3: In _value_grid (lines 350-365), for int and float types, if knob.default is 0 or False, base is set to 1. This is to prevent 0 * multiplier from always being 0. However, if knob.default is False (a boolean), it will be treated as 0 in the if condition, which is correct. But isinstance(d, int | float) will be False for bool, so base will be 1. This is fine. The round(x) for int and round(x, 3) for float are also reasonable.

    No change needed.

  • Issue 4: In _knobs_from_engine_args (lines 460-485), the default value for a Knob is first tried from f.default (dataclass field default), then from domain.default (argparse default). This order is correct. However, if f.default is dataclasses.MISSING and domain.default is None, the Knob will have default=None. This is generally fine, but some downstream logic might expect a non-None default for numeric operations (e.g., in _value_grid where base = d if ... else 1). _value_grid handles None by falling back to base=1, so this is robust.

    No change needed.

gitm/kernels/library.yaml

  • Issue 1: The library.yaml now includes cpu_offload_gb_4 and preemption_mode_swap. This is consistent with the autoresearch.py _RULES table being empty for memory_bound.

    No change needed.

  • Issue 2: The max_num_batched_tokens_dynamic intervention (lines 49-62) has value: 8192 as an "offline/predict-only fallback". This is a good addition for robustness. The value_multiplier_grid is also a good feature.

    No change needed.

  • Issue 3: The gpu_memory_utilization_dynamic intervention (lines 100-113) has value_multiplier_grid: [0.95, 1.03, 1.10] and value_max: 0.97. If the current value is, say, 0.90, then 0.90 * 1.10 = 0.99, which would be clamped to 0.97. This is correct. However, 0.90 * 0.95 = 0.855 and 0.90 * 1.03 = 0.927. The value_max only applies to the scaled result, not the multipliers themselves. This is a valid design.

    No change needed.

gitm/kernels/spec.py

  • Issue 1: The InterventionSpec now includes value_multiplier, value_multiplier_grid, value_max, value_min, and knobs. These are significant additions to support relative and joint candidates. The knob_values property correctly unifies access to single or joint knob settings.

    No change needed.

gitm/optimizer/apply.py

  • Issue 1: In apply_intervention (lines 77, 84), the _audit calls now pass knobs=_knob_values(spec). This is a good improvement for auditing, as it provides more context for multi-knob interventions.

    No change needed.

  • Issue 2: In _set_knob (lines 240-245), the logic has been updated to handle multiple knobs from spec.knobs. This is correct.

    No change needed.

  • Issue 3: In LiveEngineApplicator.apply (lines 300-302), the StructuralKnobRequiresRestart message now correctly lists multiple knobs if it's a joint candidate.

    No change needed.

  • Issue 4: In LiveEngineApplicator.apply (lines 303-325), the restart_mode logic for "serial" restarts is introduced. This involves calling _shutdown(old_engine) before _restart_fn and then _baseline_restart_fn if the candidate build fails. This is a complex sequence of operations. It's important that _shutdown and _activate are robust.

    No change needed, but the complexity is noted.

  • Issue 5: In LiveEngineApplicator._shutdown (lines 342-375), the shutdown logic is expanded to include gc.collect() and torch.cuda.empty_cache(). This is a good practice for releasing GPU memory, especially in a restart scenario. The torch.cuda.ipc_collect() is also added.

    No change needed.

  • Issue 6: In LiveEngineApplicator.measure (line 420), spec.value is now (getattr(spec, "knobs", None) or spec.value). This ensures that last_result.value correctly reflects either the single knob value or the dictionary of joint knobs.

    No change needed.

gitm/optimizer/deviation.py

  • Issue 1: The _OP_RULES (lines 29-44) have been updated with more specific kernel names for attn_score_value and lm_head. This improves the accuracy of classify_op. The comments also clarify that bare GEMMs are still unmodeled, which is an honest assessment.

    No change needed.

gitm/optimizer/monitor.py

  • Issue 1: The KernelResidual dataclass (lines 29-33) now includes t_obs_s, t_pred_s, and bound. These are used for the roofline-weighted memory fraction in classify_bottleneck.

    No change needed.

  • Issue 2: The residuals function (lines 46-70) has been refactored to match observed kernels to predicted nodes by op identity (classify_op) rather than ordinal position. This is a significant improvement for accuracy, as the previous ordinal matching was prone to misalignment.

    No change needed.

gitm/optimizer/replay.py

  • Issue 1: The _applies function (lines 29-39) now prioritizes classify_op for matching kernels to intervention applicability, falling back to substring matching. Crucially, an empty applies_to_kernels now means 0 coverage, not 100%. This is a correct and important change.

    No change needed.

gitm/optimizer/vllm_knobs.py

  • Issue 1: The KNOB_PREREQUISITES (lines 187-192) and unmet_prerequisite function (lines 195-210) are new, allowing for conditional application of knobs based on other knob states. This is a good addition for more intelligent candidate generation.

    No change needed.

  • Issue 2: The resolve_relative_value (lines 213-236) and expand_relative_candidates (lines 239-264) functions are new, enabling dynamic scaling of knob values and generating multiple candidates from a single "swept" intervention. This is a powerful feature for exploring the knob space.

    No change needed.

gitm/scheduler/loop.py

  • Issue 1: The _agg_kt_residual function (lines 196-209) has been updated to calculate a duration-weighted residual when t_obs_s and t_pred_s are available, falling back to the median r_kt. It also clamps the result to +/-1.0. This is a more robust and informative aggregation.

    No change needed.

  • Issue 2: The _ar_target_residual function (lines 212-217) is new, providing the residual of the targeted

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

Addressed this round's Claude Code Review batch (commit e0d11bc):

Real, fixed:

  • _value_grid() substituted base=1 for an int/float knob whose real default is 0 (e.g. cpu_offload_gb), fabricating a grid like [0, 1, 2, 4] that isn't a real multiplicative search around the knob's actual value. A zero default now correctly yields [] (no meaningful ratio-based grid) instead of inventing one. Updated the test fixtures relying on the old fabricated-grid behavior.
  • _searchable() re-ran self._source.knobs() (a fresh knob-surface parse — for VLLMKnobSource, a full argparse introspection) on every call; propose() calls it twice per invocation (main loop + _extra_candidates), doubling that cost for no reason since source/catalog are fixed for the proposer's lifetime. Now memoized per instance.
  • Added a one-line comment documenting classify_bottleneck's previously-undocumented tie-break (sc_score == mem_score favors idle_stall).

Verified false / already addressed, no action taken:

  • applies_to_kernels: bare-null YAML — checked every entry in library.yaml; all are explicit ([], *all_ops, or a real list). No bare nulls exist.
  • _roofline_memory_fraction normalization "mismatched semantics" — both memcpy_frac and roofline_frac are the same conceptual quantity (fraction of time attributable to memory-bound work) from two different estimators; normalizing both by the same threshold before taking max() is the intended design, not a unit mismatch.
  • largest_residual max()-on-empty-iterator — by_op is always built from res.per_kernel, which is checked non-empty on entry; can't be empty. Already assessed correct in an earlier round.
  • value_multiplier_grid/value_max "no consuming implementation" — false; confirmed expand_relative_candidates (which resolves and clamps to value_max) is wired into the live loop at gitm/scheduler/loop.py:478, not just autoresearch. Already verified false in prior rounds.
  • autoresearch_v0's reject callback "displacing" a higher-ranked candidate by rank-slot — false; select_interventions is called with top_n=len(proposals), so nothing is truncated by rank before every candidate reaches the reject veto.
  • FallbackProposer duplicate-candidate risk — false; secondary.propose() only runs when primary.propose() returns empty (specs or secondary...), so the two never both contribute.
  • _argparse_domains "arbitrary code execution" — correctly flagged as low severity; only ever called with EngineArgs, not attacker-controlled input on any current call path. No change needed.
  • StochasticProposer unbounded loop / reseed-per-call — both are existing, intentional design tradeoffs (bounded by max_attempts; reproducibility by explicit seed), not bugs.
  • _RULES empty tables for idle_stall/memory_bound — intentional, per this branch's catalog-graduation work; already discussed earlier in this PR's history.

No real inline/resolvable PR review threads exist (gh api .../pulls/56/comments still returns []).

Test run: tests/test_autoresearch.py full pass; full suite green except the known pre-existing pyarrow-missing failures (unrelated, same set as prior rounds).

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

The changes introduce a new autoresearch agent, integrating it into the existing optimization loop. The agent classifies bottlenecks, targets the largest residuals, and proposes non-catalog vLLM optimization candidates. These candidates are then routed through the same gate and rollback path as catalog interventions, ensuring safety. The changes also include significant updates to the intervention specification to support relative and joint knob changes, and robust handling of live engine restarts.

Here are some observations and suggested improvements:

Issues and Improvements

  1. Consistency in LiveEngineApplicator._shutdown cleanup
    The _vllm_decode_factory's shutdown logic includes torch.cuda.ipc_collect() for comprehensive memory cleanup, which is a good practice for GPU applications. The LiveEngineApplicator._shutdown method, which is also responsible for cleaning up GPU resources, should be consistent and include this call as well.

    File: gitm/optimizer/apply.py

    --- a/gitm/optimizer/apply.py
    +++ b/gitm/optimizer/apply.py
    @@ -399,6 +418,7 @@
             if torch.cuda.is_available():
                 torch.cuda.empty_cache()
  •            torch.cuda.ipc_collect()
       except Exception:
           pass
    
    
    
  1. Test test_restart_apply_structural_knob_kept_and_swaps_engine needs restart_fn signature update
    The LiveEngineApplicator's restart_fn now accepts a dictionary of knob_values instead of individual knob and value arguments. The test's lambda for restart_fn needs to be updated to match this new signature.

    File: tests/test_vllm_knobs_and_restart.py

    --- a/tests/test_vllm_knobs_and_restart.py
    +++ b/tests/test_vllm_knobs_and_restart.py
    @@ -106,8 +106,8 @@
     def test_restart_apply_structural_knob_kept_and_swaps_engine():
         e0 = _TpsEngine(100.0)
     
    -    def restart_fn(old, knob, value):
    -        assert knob == "block_size" and value == 16
    +    def restart_fn(old, knob_values):
    +        assert knob_values == {"block_size": 16}
             return _TpsEngine(200.0)  # the "restarted" engine is faster
     
         app = LiveEngineApplicator(e0, throughput_fn=_tps_of, restart_fn=restart_fn)
  2. Test test_run_loop_live_engine_keeps_winning_knob_and_rolls_back_unswappable assertion update
    With the introduction of value_multiplier_grid and max_num_seqs_dynamic, the max_num_seqs knob is no longer set to a hardcoded 256. Instead, it's scaled relative to the engine's starting value. The assertion should check that the value has increased from the initial 32, rather than asserting an exact 256.

    File: tests/test_vllm_embodiment.py

    --- a/tests/test_vllm_embodiment.py
    +++ b/tests/test_vllm_embodiment.py
    @@ -299,7 +299,10 @@
     
     # The hot-swappable winning knob is kept with a measured positive delta.
    -    assert engine.max_num_seqs == 256
    +    # max_num_seqs_dynamic now sweeps relative to the engine's starting value
    +    # (32) rather than always jumping to one hardcoded 256 — assert it was
    +    # raised and kept, not a specific literal that depends on where the sweep
    +    # started.
    +    assert engine.max_num_seqs > 32
         import json
     
         claims = json.loads((Path(out["run_dir"]) / "ranked_candidates.json").read_text())
  3. Test test_run_loop_scheduler_stats_feed_attribution_and_claims assertion update
    Similar to the previous point, the max_num_seqs assertion needs to be updated to reflect the dynamic scaling. The initial value for max_num_seqs in _FullEngine is 64.

    File: tests/test_vllm_knobs_and_restart.py

    --- a/tests/test_vllm_knobs_and_restart.py
    +++ b/tests/test_vllm_knobs_and_restart.py
    @@ -256,7 +256,10 @@
     # The engine signal reached attribution: under-filled-batch cause is present.
         signals = {c["signal"] for c in residuals["scheduler_causes"]}
         assert "under_filled_batch" in signals
     # And it reached the claim that it motivates (max_num_seqs), in the report.
         assert "scheduler[under_filled_batch]" in out["report_md"]
    -# The hot-swap won and was kept.
    -    assert engine.scheduler_config.max_num_seqs == 256
    +# The hot-swap won and was kept. max_num_seqs_dynamic now sweeps relative
    +# to the engine's starting value (64) rather than one hardcoded 256, so
    +# assert it was raised and kept rather than pin an exact literal.
    +    assert engine.scheduler_config.max_num_seqs > 64
     # Scheduler summary surfaced in the run summary (synchronous first sample).
         assert out["summary"]["scheduler_stats"] is not None

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_roofline_memory_fraction uses wrong normalization for classify_bottleneck
The roofline fraction is normalized by _MEMCPY_THRESHOLD (0.25), but it's already a fraction of kernel time (0–1), not a raw memcpy fraction. A 60% memory-bound roofline prediction produces mem_score = 0.6 / 0.25 = 2.4, which will always dominate sc_score and bias everything toward MEMORY_BOUND. The roofline signal should either have its own threshold or be used additively, not substituted into the same denominator.

largest_residual uses max() over a generator that always has at least one item, but the mean <= 0 guard is applied after max()
If all kernels of the max op have r_kt ≤ 0, None is correctly returned. However, if by_op is empty after the loop (impossible given the if not res.per_kernel guard), max() would raise. This is safe as-is but the guard and the max() are separated in a way that obscures their relationship—worth a comment.

_value_grid zero-default guard excludes valid knobs with default=False
d in (0, False) treats False the same as 0 due to Python's bool subclassing int. A bool knob should already be caught by the knob.kind == "bool" branch above, but if a knob is kind="int" with default=False (possible from annotation-only introspection), it silently returns [] rather than gridding from an assumed base.

_RULES is documented as having memory_bound entries in docs/autoresearch.md but the code has them empty
The doc says cpu_offload_gb and preemption_mode were moved to library.yaml. But the table in the docs still shows those rows. Minor, but will mislead readers.

gpu_memory_utilization_dynamic multiplier grid [0.95, 1.03, 1.10] applied to the default value 0.92 yields 0.9476, 0.9476, 1.012
The value_max: 0.97 clamp is metadata—there's no code shown enforcing it. 0.92 * 1.10 = 1.012 > 1.0 is an invalid utilization fraction. The clamp needs to be enforced at the application layer or the grid values should be absolute, not relative.

swap_space_dynamic comment acknowledges 0 * multiplier = 0 but treats this as acceptable fallback
If the running engine has swap_space=0, all multiplied values collapse to 0, the sweep produces the same candidates as the fallback value=4, and the actual current value is silently ignored. The live-scaling intent is broken for this common default.


🔒 Security

_annotation_kind uses str(annotation).lower() on arbitrary type annotations
If a field annotation is a complex forward reference or a user-controlled string, this could yield unexpected matches. Low severity here, but worth using typing.get_origin/typing.get_args consistently instead.


⚡ Performance

_searchable() cache is per-instance but load_library() is called in __init__ on every _ProposerBase instantiation
If multiple proposers are created (e.g. FallbackProposer(EngineArgsProposer(), TableProposer())), load_library() parses the YAML twice. This is a startup cost, not a hot path, but a module-level cached load would be cleaner.

StochasticProposer sampling loop has max_attempts = max(n*4, len(eligible)*4) but uses rng.choices (with replacement)
With a small eligible set and high collision rate, this can loop O(n * len(eligible)) times. For large knob surfaces this is fine; for a tiny fallback catalog (3 knobs, n_samples=6), max_attempts=12 may not yield 6 unique pairs. The loop will silently return fewer than n_samples candidates with no warning.


📊 Reproducibility

StochasticProposer seed is a constructor parameter defaulting to 0
Every default instantiation uses the same seed. If two StochasticProposer instances run in the same process with the same bottleneck class, they produce identical candidates. The seed should either be drawn from a passed RNG or documented explicitly as "same seed = same candidates by design."

_visible_gpu_count() wraps torch.cuda.device_count() with or 1
device_count() returns 0 on a CPU-only build (not an exception), so or 1 handles it correctly. But the function is called at proposal time, not at construction time—if GPU topology changes mid-run (e.g. MIG reconfiguration), cached knob lists (_searchable_cache) become stale without any signal.


💡 Suggestions

autoresearch_v0 silently ignores apply_error from apply_intervention
The AutoresearchResult.apply_error field is populated from applied.error, but apply_intervention's return type isn't shown here—if .error is not always present, this will AttributeError at runtime for some applicator implementations. An explicit getattr(applied, "error", None) would be safer.

FallbackProposer has no logging when it falls back
Silent fallback to TableProposer makes it hard to diagnose why the generative path produced nothing (empty knob surface? all catalog-excluded? no affine knobs?). A single debug log at the fallback point would make this diagnosable without adding the autoresearch.json machinery.

_joint_prerequisite_candidates iterates KNOB_PREREQUISITES then knobs — O(prerequisites × knobs)
For a large EngineArgs surface (~100 knobs) and a non-trivial prerequisite list, this is a nested scan. Building by_name (already done) and iterating only over prerequisite pairs would reduce this to O(prerequisites).

value_multiplier_grid in library.yaml is new metadata with no code shown consuming it
The YAML adds value_multiplier_grid, value_max, and shared anchors, but without the corresponding parser/loader changes these fields are silently ignored. If InterventionSpec doesn't handle value_multiplier_grid, the "dynamic sweep" entries will only ever use the hardcoded value: fallback—which is exactly the static behavior they were meant to replace.

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

This round's Gemini review: no action needed.

Every item in the review is explicitly marked "No change needed" by Gemini itself, except one suggested diff — removing `cpu_offload_gb`/`preemption_mode` from `_FALLBACK_KNOBS` in `autoresearch.py` on the theory that the offline fallback path bypasses the catalog-exclusion filter and could re-propose knobs that already graduated to `library.yaml`.

Verified false: catalog exclusion happens downstream in `_ProposerBase._searchable()`, which filters every knob (regardless of source — real EngineArgs introspection or `_FALLBACK_KNOBS`) against `self._catalog`, built as `{s.knob for s in load_library()}`. Both `cpu_offload_gb` and `preemption_mode` are in that set (their catalog entries' `knob:` field), so the fallback path already excludes them — no separate code path bypasses the filter. This exact behavior has its own regression test: `test_engineargs_offline_fallback_runs_without_vllm` asserts `p.propose("memory_bound") == []` on the fallback path specifically because these two graduated to the catalog.

CI status: all checks (`lint`, `pytest` 3.10/3.11/3.12, `gemini-review`, `review`) now pass on commit e0d11bc. `gh pr view` reports `mergeable: MERGEABLE` (no conflicts) — the remaining `mergeStateStatus: BLOCKED` is a required-review/branch-protection gate, not a code or CI issue.

No real inline/resolvable PR review threads exist (`gh api .../pulls/56/comments` still returns `[]`).

🤖 Addressed by Claude Code

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

Addressed this round's 3 review comments (commit aea634d):

Real, fixed:

  • `LiveEngineApplicator._shutdown` (`apply.py`) was missing `torch.cuda.ipc_collect()`, unlike `gitm/workloads.py`'s `_vllm_decode_factory` shutdown path, which already calls it alongside `gc.collect()`/`empty_cache()`. Brought the two in line — a restart-apply now releases GPU memory as completely as a fresh workload boot. (Note: an earlier Gemini pass had claimed this call already existed — it didn't; this round's second Gemini review caught the real gap correctly.)
  • `docs/autoresearch.md`'s fallback-table (`_RULES`) rows for `memory_bound` were stale — still listing `cpu_offload_gb`/`preemption_mode` as active `_RULES` entries, even though that table is empty in code (both graduated to `library.yaml` earlier this session) and the `idle_stall` row already reflected its own emptiness. Updated to match.

Verified false / already addressed, no action taken:

  • 1st Gemini comment: identical rehash of an earlier round's review (same `_FALLBACK_KNOBS` suggestion) — already verified false last round (catalog filtering happens in `_searchable()`, confirmed by `test_engineargs_offline_fallback_runs_without_vllm`).
  • 2nd Gemini comment's other 3 suggested test diffs — all stale against current code: `test_restart_apply_structural_knob_kept_and_swaps_engine` already uses the dict `knob_values` signature; `test_vllm_embodiment.py`'s `max_num_seqs` assertion is already `> 32`; `test_vllm_knobs_and_restart.py`'s scheduler-stats test assertion is already `> 64`. The other two `== 256` hits the bot may have conflated (`test_get_set_via_structured_path`, `test_hotswap_scheduling_knob_kept`) test `set_knob`/`apply_intervention` directly with an explicit literal spec — correctly hardcoded, not the dynamic-sweep path.
  • Claude Code Review's `_roofline_memory_fraction` normalization, `largest_residual`, `value_multiplier_grid` "no consumer" — all re-flagged from earlier rounds, all previously verified false with reasoning that still holds.
  • `_value_grid`'s `d in (0, False)` guard "excluding valid knobs with default=False" — intentional: a `kind="int"` knob with a bool default is a degenerate/malformed introspection result; returning `[]` instead of fabricating a base=1 grid is the safer behavior, consistent with this session's zero-default fix.
  • `swap_space_dynamic`'s "0 * multiplier = 0" collapse — already explicitly documented as an intentional fallback in the YAML comment, not a silent bug.
  • Remaining perf/logging/reproducibility suggestions (`_searchable_cache` GPU-topology staleness, `FallbackProposer` logging, `_joint_prerequisite_candidates` complexity, `StochasticProposer` seed/loop behavior, `apply_error` getattr) — nice-to-haves or already-intentional design, not bugs; not acted on.

No real inline/resolvable PR review threads exist (`gh api .../pulls/56/comments` still returns `[]`).

Targeted test suite (`test_apply_rollback.py`, `test_vllm_knobs_and_restart.py`, `test_run_loop_workload.py`, `test_vllm_embodiment.py`, `test_autoresearch.py`, `test_catalog_unify.py`) is green.

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid skips False defaults silently

if not isinstance(d, int | float) or d in (0, False):
    return []

False is == 0 in Python, so bool-typed fields that reach this branch with default=False (the common case) return an empty grid. But kind == "bool" is handled earlier, so this only fires when kind is "int" or "float" and the default happens to be a falsy int. Still, a default of 0 for a real int knob (e.g. cpu_offload_gb=0) silently produces no grid — the caller gets nothing to search and the knob is dropped from _searchable. The fallback catalog has cpu_offload_gb with default=0 and kind="int", so it would be silently excluded in _searchable despite being explicitly listed.

roofline_frac uses _MEMCPY_THRESHOLD as its denominator

mem_score = max(mem_score, roofline_frac / _MEMCPY_THRESHOLD)

The roofline fraction (fraction of kernel time that is memory-bound, 0–1) is divided by _MEMCPY_THRESHOLD = 0.25, meaning a 50%-memory-bound workload scores 2.0 — same as a workload where half of all GPU ops are literal memcpy transfers. These are different quantities on different scales; mixing them under one threshold without a separate normalizer conflates two unrelated signals.

largest_residual calls max() on a potentially empty iterator

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

by_op is populated from res.per_kernel (guarded by if not res.per_kernel: return None), so it can't be empty here — but if res.per_kernel is a non-empty iterable that yields no items with a non-None kr.op, by_op would be empty and max() raises ValueError. Defensive if not by_op check would eliminate the ambiguity.

gpu_memory_utilization multiplier grid can exceed 1.0

value_multiplier_grid: [0.95, 1.03, 1.10]
value_max: 0.97

The grid contains 1.03 and 1.10, which are ratios > 1. If the current value is, say, 0.90, these produce 0.927 and 0.99 — both valid. But if the current value is already 0.93, the grid produces 0.9579, 0.9579, 1.023 — the last one silently exceeds value_max: 0.97 and would need to be clamped by whoever consumes value_multiplier_grid. There is no clamping logic visible in this diff.

swap_space comment acknowledges but doesn't fix the zero-default collapse

value: 4  # fallback; also the outcome when current swap_space is 0 (common
  # default) — 0 * any multiplier stays 0, so the sweep collapses to this.

The comment admits the multiplier grid degenerates to nothing useful (0×anything=0), but the fix (use an absolute grid instead) isn't implemented.


⚡ Performance

_searchable cache is per-instance but VLLMKnobSource.knobs() re-runs argparse introspection on every call
The cache in _ProposerBase._searchable prevents repeated calls from the same proposer instance, but if multiple proposer instances share the same VLLMKnobSource, each instance rebuilds independently. This is a minor concern for the current use case but could matter if FallbackProposer instantiates both.

StochasticProposer rejection loop has a fixed-ratio retry bound

max_attempts = max(self._n * 4, len(eligible) * 4)

With a large knob surface (real EngineArgs has ~100+ fields) and small n_samples=6, this cap is 400+ — almost certainly fine, but the deduplication set seen is keyed on (knob.name, value) not candidate identity, so the same value proposed for a different bottleneck class in a hypothetical multi-class call would be incorrectly deduplicated. Not a current issue since propose() is called once per class.


📊 Reproducibility

StochasticProposer seed is fixed at construction, not per-call

rng = random.Random(self._seed)

The RNG is re-seeded from self._seed on every propose() call, which means two successive calls to the same proposer with the same bottleneck_class produce the same samples. This is probably the intended behavior, but it means adding a new class earlier in the pipeline changes nothing about this class's samples — good — whereas changing n_samples does change them because the draw sequence shifts. Worth documenting as a contract.

_FALLBACK_KNOBS includes cpu_offload_gb and preemption_mode which are also in library.yaml

_FALLBACK_KNOBS: tuple[Knob, ...] = (
    Knob("cpu_offload_gb", "int", default=0),
    Knob("preemption_mode", "enum", ...),
    ...
)

The doc says "autoresearch only proposes outside the catalog." The _catalog exclusion in _searchable handles this at runtime, but cpu_offload_gb with default=0 would also be filtered by the _value_grid zero-default bug above — two separate filters both hiding the same knob for different reasons makes it harder to audit which one is actually responsible.


💡 Suggestions

_annotation_kind is called on stringified annotations in __future__ annotations modules

text = str(annotation).lower()

With from __future__ import annotations, all annotations are strings at runtime. str(annotation) on a string like "Optional[int]" works, but _field_kind_and_choices tries typing.get_origin(annotation) on what may be a plain string — get_origin("Optional[int]") returns None, so Literal extraction silently fails. The fallback to _annotation_kind then sees "optional[int]" as a string-match and correctly picks "int", so the final result is usually right, but the Literal path never fires for vLLM's own source if it uses from __future__ import annotations. Consider typing.get_type_hints() on the class rather than per-field annotation objects.

apply_error field on AutoresearchResult is None in the "rejected before apply" case and also in the "applied and rolled back on a negative delta" case
The docstring correctly distinguishes these, but a consumer iterating results sees apply_error=None, rolled_back=True, measured_delta=None for both a gate-rejected candidate and one that was applicable but the gate threw. Adding a sentinel (e.g. applicable=False for gate-rejected vs applicable=True, apply_error=...) already exists, but the combination applicable=True, apply_error=None, measured_delta=None, rolled_back=True is still ambiguous — it means "applied, then rollback triggered before measurement completed." A brief note in the dataclass field docstring or an apply_status enum would remove the need to reconstruct this logic at the call site.

_RULES is effectively empty for two of the three classes

"idle_stall": [],
"memory_bound": [],

FallbackProposer falls through to TableProposer only when the primary yields nothing. For idle_stall and memory_bound, TableProposer also yields nothing — the fallback produces the same empty list as the primary, making the FallbackProposer chain a no-op for 2/3 classes unless EngineArgsProposer succeeds. This is fine offline but should be a documented invariant rather than a silent empty result.

value_multiplier_grid in library.yaml is introduced as a new field with no schema validation visible in this diff
If the YAML loader doesn't handle unknown keys, this silently passes through as an unrecognised attribute. Conversely, if the loader is strict, existing consumers will break. The schema for InterventionSpec/library.yaml should be updated atomically with this diff.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

autoresearch: generate and gate non-catalog vLLM optimization candidates

The changes introduce a significant new feature with a robust design, clear separation of concerns, and comprehensive test coverage. The implementation of dynamic/relative interventions, joint candidates, and the detailed engine restart/shutdown logic is particularly well-executed. The documentation is also thorough.

Here are a few minor suggestions for improvement:

### `docs/autoresearch.md`

The `Fallback table` section mentions that `idle_stall` and `memory_bound` have `(none by default)`. While the commit body explains that `cpu_offload_gb` and `preemption_mode` graduated to `library.yaml`, this isn't explicitly stated in the user-facing documentation table. Clarifying this would improve readability and understanding of why these classes have no default autoresearch rules.

```diff
--- a/docs/autoresearch.md
+++ b/docs/autoresearch.md
@@ -199,9 +199,9 @@
 | Class | Knob | Value | Rationale |
 |-------|------|-------|-----------|
 | `idle_stall` | *(none by default)* | — | dependent prefill/DBO knobs are filtered unless represented by reviewed catalog entries |
-| `memory_bound` | *(none by default)* | — | `cpu_offload_gb` and `preemption_mode` graduated to `library.yaml` (see below) |
+| `memory_bound` | *(none by default)* | — | `cpu_offload_gb` and `preemption_mode` graduated to `library.yaml` |
 | `compute_bound` | `compilation_config` | 3 | torch.compile level 3: kernel fusion + piecewise CUDA graphs |
 
 The **rationales are plausibility arguments, not measured claims**. Each proposal
 carries `expected_delta_mean = 0.05` (`lo = 0.0`, `hi = 0.15`) — a modest,
 honestly-unproven range — and a `source` that says so. Only the measured A/B
 turns a proposal into a real number.

@nicholaslawrence-hub

Copy link
Copy Markdown
Collaborator Author

This round's 2 review comments: no code changes needed.

Claude Code Review — every "bug" is either a rehash already verified false in earlier rounds (`_roofline_memory_fraction` normalization, `largest_residual` max()-on-empty, `value_multiplier_grid` "no schema/consumer"), or the bot's own analysis concludes it's a non-issue:

  • `_value_grid` returning `[]` for `cpu_offload_gb` (default=0) — the review itself notes `cpu_offload_gb` is also excluded by the catalog filter in `_searchable` (it graduated to `library.yaml`), so the zero-default guard is a redundant, harmless second filter on an already-excluded knob, not a bug.
  • `_searchable` cache / `StochasticProposer` dedup-set scoping — bot itself: "minor concern for the current use case," "not a current issue since `propose()` is called once per class."
  • `_annotation_kind`/stringified-annotation Literal extraction under `from future import annotations` — checked `_knob_domain` (autoresearch.py:607-619): argparse `choices` metadata is tried first and only falls through to annotation parsing when argparse supplies none, so this edge case doesn't affect the primary path. Bot's own conclusion: "the final result is usually right."
  • `apply_error`/`AutoresearchResult` ambiguity suggestion, `_RULES` empty-classes note — design suggestions, not bugs; not acted on.

Gemini review — suggests removing "(see below)" from the doc line I just added last round. Purely cosmetic wording, no functional or clarity difference either way; not acted on.

No real inline/resolvable PR review threads exist (`gh api .../pulls/56/comments` still returns `[]`).

CI on aea634d: all checks pass (lint, pytest ×3, gemini-review, review); `mergeable: MERGEABLE`.

🤖 Addressed by Claude Code

…m-decode path

_shutdown() called gc.collect() + torch.cuda.empty_cache() but not
torch.cuda.ipc_collect(), unlike gitm/workloads.py's
_vllm_decode_factory shutdown, which already calls all three. Brought
the two in line so a restart-apply releases GPU memory as completely
as a fresh workload boot.

docs: memory_bound fallback-table rows were stale after the
cpu_offload_gb/preemption_mode catalog graduation

_RULES["memory_bound"] is empty in code (those two knobs moved to
library.yaml), matching how the idle_stall row already reads "(none
by default)" — but the memory_bound rows still listed them as if
they were still _RULES entries. Updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Gemini

--- a/gitm/kernels/library.yaml
+++ b/gitm/kernels/library.yaml
@@ -109,7 +109,7 @@
     value: 0.92  # offline/predict-only fallback when there's no live engine to scale
     # A fraction near 1.0, not a count — the shared *0.5/*4 ladder would be
     # invalid here, so a gentler one, clamped below 1.0.
-    value_multiplier_grid: [0.95, 1.03, 1.10]
+    value_multiplier_grid: [0.95, 0.97]
     value_max: 0.97
     applies_to_kernels: *all_ops  # raises the batch-slot ceiling for every op
     expected_delta_mean: 0.04

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review

🐛 Bugs

_value_grid zero-default silently drops valid search points

if not isinstance(d, int | float) or d in (0, False):
    return []  # zero/non-numeric default: no meaningful multiplicative grid

False is == 0 in Python, so bool knobs with default=False (the majority) fall through to this early return before the kind == "bool" branch. A Knob("enable_foo", "bool", default=False) yields an empty grid. The bool branch should be checked first, or the guard should exclude kind == "bool":

if knob.kind == "bool":
    return [not bool(knob.default)]
if knob.kind in ("int", "float"):
    if not isinstance(d, int | float) or d == 0:  # don't conflate False
        return []

gpu_memory_utilization_dynamic multiplier grid can exceed 1.0
In library.yaml, the entry uses value_multiplier_grid: [0.95, 1.03, 1.10] with value_max: 0.97, but 0.92 * 1.10 = 1.012. Nothing in the diff shows value_max being enforced during grid application — if the replay/apply path doesn't clamp it, vLLM will reject the value at engine build time and the result is a wasted rollback trial with an opaque error.

largest_residual uses max() on a potentially empty by_op

op, values = max(by_op.items(), key=lambda kv: sum(kv[1]) / len(kv[1]))

res.per_kernel is checked to be non-empty, but if every kr.op is an empty string or None, by_op could still be empty (or all ops average ≤ 0 but the max call still runs). The mean <= 0 guard fires after the max call, so a degenerate but non-empty per_kernel with one zero-time kernel is fine — but worth a comment that the guard is post-max, not pre.

swap_space_dynamic comment is wrong about the zero case

# also the outcome when current swap_space is 0 (common
# default) — 0 * any multiplier stays 0, so the sweep collapses to this.

This admits the dynamic sweep silently degrades to a single hardcoded value when swap_space=0, with no warning to the user or log entry. This is a silent correctness hole: the "dynamic" sweep isn't dynamic in the most common case.

_knobs_from_engine_args uses f.type (a string under from __future__ import annotations)

kind, choices = _knob_domain(f.type, domain)

dataclasses.fields() returns the raw annotation, which under from __future__ import annotations is a string, not a type object. _field_kind_and_choices_annotation_kind does str(annotation).lower(), so string matching works, but typing.get_origin/get_args on a string always returns None, silently falling back for every Literal[...] annotation. The code comments acknowledge this but it's a silent degradation path that could be detected and warned.

StochasticProposer weights check is wrong

if not any(weights):
    return []

weights is a list of floatany([0.0, 0.0]) is False correctly, but any([0.15, 0.0]) is True even if epsilon=0 and nothing is affine. When epsilon=0 and no knob is affine, all weights are 0.0 and rng.choices(..., weights=[0.0, ...]) raises ValueError. The check should be if not eligible or not any(w > 0 for w in weights).


🔒 Security

_argparse_domains calls engine_args_cls.add_cli_args on an untrusted class object
If EngineArgs is monkey-patched or the import resolves to a malicious package, this runs arbitrary code during introspection. The try/except Exception swallows errors silently. Not a critical risk in a controlled ML environment, but the broad except makes supply-chain issues invisible.


⚡ Performance

_searchable_cache is per-instance but load_library() is called in __init__ on every instantiation

self._catalog = ... {s.knob for s in load_library()}

If load_library() does file I/O (parsing library.yaml), and EngineArgsProposer is constructed per loop iteration, this re-parses the YAML every time. The result is constant — a module-level cache or functools.lru_cache on load_library would eliminate redundant I/O.

_op_present iterates all kernels with classify_op per call, called twice per autoresearch_v0 invocation
Once in autoresearch_v0 (for target_op) and the result is used to build applies_to_kernels in propose/proposer.propose. For large traces this is repeated linear work. Cache the result or pass it through.


📊 Reproducibility

StochasticProposer seed is an instance parameter, not a run parameter
The same StochasticProposer instance reused across two loop iterations produces identical proposals (same seed, same Random object reconstructed). If the intent is variation across runs, the seed should be derived from a run counter or timestamp. If the intent is reproducibility, the rng should be stored as instance state (not reconstructed each propose() call) so sequential calls advance the sequence rather than replaying it.

_visible_gpu_count() is called at knob-generation time, not at loop-init time
If torch.cuda.device_count() changes between runs (e.g. GPU hot-plug in a cloud environment), the knob surface silently changes mid-experiment, affecting reproducibility of the candidate set.


💡 Suggestions

autoresearch_v0 ignores apply_intervention's error field on non-apply path

apply_error=applied.error if applied else None,

When reason is not None (rejected), apply_error is None — correct. But when apply_intervention raises an unhandled exception (not caught inside apply_intervention), it propagates out of autoresearch_v0 entirely, leaving no AutoresearchResult for that candidate. Consider wrapping in a try/except and recording the failure.

_RULES for idle_stall and memory_bound are empty lists
TableProposer will always return [] for those two classes, making FallbackProposer always fall back to TableProposer (which returns [] again) for those classes. The fallback chain is a no-op for 2 of 3 classes. This is documented intent, but it means the "fallback" label is misleading — worth a comment that TableProposer is currently only functional for compute_bound.

value_multiplier_grid and value_max in library.yaml have no schema or parser
The YAML introduces new fields (value_multiplier_grid, value_max) but the diff shows no changes to the schema loader or InterventionSpec. These fields will be silently ignored unless the parser is updated, making the "dynamic sweep" entries dead config.

_NON_TUNABLE_HINTS uses substring match on "name"
This will incorrectly exclude any knob containing "name" as a substring — e.g. a hypothetical filename_format or batch_rename_policy. More precisely, it would exclude tokenizer_mode (contains no hint) but could catch max_model_len only if "model" matches — which it does. max_model_len is arguably tunable (it sets the sequence length ceiling). Consider using whole-word or prefix matching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants