Skip to content

Commit 2f70f0f

Browse files
jiafatomCopilot
andauthored
Allow overriding past_present_share_buffer in LMEvaluator ortgenai backend (microsoft#2569)
## Describe your changes The `ortgenai` lm-eval backend (`LMEvalORTGenAIEvaluator`) always derived `past_present_share_buffer` from the exported `genai_config.json`, with **no way to override it** from a recipe/evaluator config. Some models (e.g. **Gemma 4**) export with shared past/present buffers *enabled* but require it *disabled* for correct KV-cache handling during evaluation. Additionally, `LMEvaluator` gave recipe authors no way to enable a model's chat template or few-shot prompting, so instruction-tuned models were evaluated 0-shot on raw prompts and scored near-random. Today these gaps force recipe authors to bypass `LMEvaluator` and write a standalone `eval.py` that calls `lm_eval.simple_evaluate` directly — the declarative Olive config path simply produces wrong results for these models. This PR makes these settings overridable end-to-end and adds optional sample logging: - **`LMEvalORTGenAIEvaluator`** now accepts an explicit `past_present_share_buffer` argument that takes precedence over the exported value. The resolution logic is extracted into a small `_resolve_past_present_share_buffer(override, genai_config)` helper (explicit override wins; otherwise fall back to the exported value, defaulting to `False`). - **`LMEvaluator`** gains a `model_args` dict that is forwarded verbatim to the lm-eval model backend constructor, so genai-specific options can be set from the evaluator config. User-provided `model_args` override the Olive-derived defaults (`batch_size`, `max_length`, `ep`, ...). Default is `{}`, so existing configs are unaffected. - **`LMEvaluator` prompting controls**: new `apply_chat_template`, `system_instruction`, `fewshot_as_multiturn` and `num_fewshot` options, forwarded to `lm_eval.simple_evaluate`. Instruction-tuned models score near-random on multiple-choice tasks when evaluated 0-shot on raw prompts; wrapping requests in the model's chat template and matching the card's few-shot protocol fixes this. Empirically, on a Gemma-4 E2B export a German MMMLU subset went from ~0.16–0.24 (0-shot, no template) to ~0.52–0.68 (5-shot + chat template). Defaults preserve lm-eval's legacy behavior (no chat template, no system prompt, task-default few-shot), so existing configs are unaffected. - **`LMEvaluator` sample logging**: a new `sample_log_num` (and optional `sample_log_dir`) option. When `sample_log_num > 0`, lm-eval's `log_samples` is enabled and the first N per-task predictions — question, options, resolved prediction (argmax of choice loglikelihoods to letter + text, or the raw generation), target, and `acc` — are written to `<task>_samples.jsonl`. Rather than duplicating the writer, `OliveEvaluator.save_sample_log` is decoupled to take a `(name, sample_log_dir)` pair instead of a `Metric`, so `LMEvaluator` reuses the exact same JSONL writer as the accuracy evaluators; a small `_lmeval_samples_to_output` helper maps lm-eval sample records into an `OliveModelOutput` (carrying question/options/prediction_index/acc via `extras`). This makes it possible to inspect why a task scores low (e.g. distinguishing a genuinely weak/quantized model from a broken eval). Default is `0`, so existing configs are unaffected. With this, such models can be evaluated with a standard `LMEvaluator` config instead of a bespoke script: ```json { "evaluators": { "mmlu": { "type": "LMEvaluator", "tasks": ["mmlu"], "model_class": "ortgenai", "num_fewshot": 5, "apply_chat_template": true, "fewshot_as_multiturn": true, "sample_log_num": 100, "model_args": { "past_present_share_buffer": false } } } } ``` ## Checklist before requesting a review - [x] Add unit tests for this change. - [x] Make sure all tests can pass. - [ ] Update documents if necessary. - [x] Lint and apply fixes to your code by running `lintrunner -a` - [x] Is this a user-facing change? If yes, give a description of this change to be included in the release notes. **Release note:** `LMEvaluator` now accepts a `model_args` dict forwarded to the lm-eval model backend; `apply_chat_template`, `system_instruction`, `fewshot_as_multiturn` and `num_fewshot` prompting options forwarded to `lm_eval.simple_evaluate` (so instruction-tuned models can be evaluated with their chat template and the card's few-shot protocol); and a `sample_log_num`/`sample_log_dir` option to log the first N per-task predictions vs. targets to a JSONL file for debugging (reusing the accuracy evaluators' `save_sample_log` writer). The `ortgenai` backend accepts an explicit `past_present_share_buffer` override (previously only read from `genai_config.json`). ## (Optional) Issue link --------- Co-authored-by: jiafatom <jiafatom@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a01bae9 commit 2f70f0f

5 files changed

Lines changed: 311 additions & 39 deletions

File tree

olive/evaluator/lmeval_ort.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,17 @@ def _normalize_provider_name(cls, ep: str) -> tuple[str, str]:
545545
normalized_ep = str(ep).lower().replace("executionprovider", "")
546546
return normalized_ep, cls._ORT_GENAI_PROVIDER_NAMES.get(normalized_ep, normalized_ep)
547547

548+
@staticmethod
549+
def _resolve_past_present_share_buffer(override: bool | None, genai_config: dict) -> bool:
550+
"""Resolve the ``past_present_share_buffer`` search option.
551+
552+
An explicit ``override`` takes precedence; otherwise fall back to the exported
553+
``genai_config.json`` value (defaulting to ``False`` when absent).
554+
"""
555+
if override is not None:
556+
return override
557+
return genai_config.get("search", {}).get("past_present_share_buffer", False)
558+
548559
def __init__(
549560
self,
550561
pretrained: str,
@@ -553,6 +564,7 @@ def __init__(
553564
ep: str = "follow_config",
554565
ep_options: dict | None = None,
555566
device: str = "cpu",
567+
past_present_share_buffer: bool | None = None,
556568
**kwargs,
557569
):
558570
"""Initialize the evaluator.
@@ -563,6 +575,9 @@ def __init__(
563575
:param ep: The execution provider to use. "follow_config" will use the provider specified in the genai_config file
564576
:param ep_options: The options to use for the execution provider. Only applicable if ep is not "follow_config"
565577
:param device: The device to run log likelihood calculations on
578+
:param past_present_share_buffer: Override the exported ``search.past_present_share_buffer`` setting. When
579+
``None`` (default) the value from ``genai_config.json`` is used. Some models (e.g. Gemma 4) export with
580+
shared buffers enabled but require it disabled for correct KV-cache handling during evaluation.
566581
"""
567582
if og is None:
568583
raise ImportError("onnxruntime-genai is not installed.")
@@ -602,7 +617,10 @@ def __init__(
602617
self._eot_token_id = self._eos_token_ids[0]
603618
# Mirror the exported GenAI cache-sharing setting when creating GeneratorParams.
604619
# Artifacts with shared past/present buffers require the same search option at runtime.
605-
self._past_present_share_buffer = genai_config["search"].get("past_present_share_buffer", False)
620+
# An explicit override (e.g. from a recipe config) takes precedence over the exported value.
621+
self._past_present_share_buffer = self._resolve_past_present_share_buffer(
622+
past_present_share_buffer, genai_config
623+
)
606624
self.params = og.GeneratorParams(self.model)
607625
self.params.set_search_options(
608626
max_length=self.max_length,

olive/evaluator/olive_evaluator.py

Lines changed: 124 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -260,24 +260,28 @@ def compute_accuracy(metric: Metric, model_outputs: Union[tuple, NamedTuple], ta
260260
return evaluate_backend_cls().measure(model_outputs, targets, metric)
261261

262262
@staticmethod
263-
def save_sample_log(metric: Metric, inference_output: "OliveModelOutput", targets: Any, num_samples: int) -> None:
263+
def save_sample_log(
264+
name: str, sample_log_dir: Optional[str], inference_output: "OliveModelOutput", targets: Any, num_samples: int
265+
) -> None:
264266
"""Save top N sample predictions and ground truth to a JSONL file.
265267
266268
Each line in the output file is a JSON object with 'index', 'prediction', and 'target'
267269
fields. When the inference output carries per-sample ``extras`` (e.g. the input prompt and
268-
the vision/audio file name), those key/value pairs are merged into each record as well.
269-
For tensor data, values are converted to Python scalars or lists.
270-
This is best-effort: filesystem or serialization errors are logged as warnings.
270+
the vision/audio file name, or lm-eval question/options metadata), those key/value pairs are
271+
merged into each record as well. For tensor data, values are converted to Python scalars or
272+
lists. The log is written to ``<sample_log_dir>/<name>_samples.jsonl`` (current working
273+
directory when ``sample_log_dir`` is not set). This is best-effort: filesystem or
274+
serialization errors are logged as warnings.
271275
"""
272276
if num_samples <= 0:
273277
return
274278

275279
try:
276280
preds = inference_output.preds
277281
extras = getattr(inference_output, "extras", None)
278-
output_dir = Path(metric.sample_log_dir) if metric.sample_log_dir else Path.cwd()
279-
# Sanitize metric name to prevent path traversal
280-
safe_name = Path(metric.name).name.replace("/", "_").replace("\\", "_") or "metric"
282+
output_dir = Path(sample_log_dir) if sample_log_dir else Path.cwd()
283+
# Sanitize name to prevent path traversal
284+
safe_name = Path(name).name.replace("/", "_").replace("\\", "_") or "metric"
281285
output_dir.mkdir(parents=True, exist_ok=True)
282286
log_path = output_dir / f"{safe_name}_samples.jsonl"
283287

@@ -311,11 +315,11 @@ def _to_serializable(val):
311315
record[key] = _to_serializable(value)
312316
record["prediction"] = _to_serializable(pred_val)
313317
record["target"] = _to_serializable(target_val)
314-
f.write(json.dumps(record, ensure_ascii=False) + "\n")
318+
f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
315319

316320
logger.info("Saved %d sample predictions to %s", n, log_path)
317321
except Exception as e:
318-
logger.warning("Failed to save sample log for metric '%s': %s", metric.name, e)
322+
logger.warning("Failed to save sample log for '%s': %s", name, e)
319323

320324
@staticmethod
321325
def latency_helper(latencies) -> dict:
@@ -793,7 +797,9 @@ def _evaluate_onnx_accuracy(
793797
inference_output, targets = self._inference(
794798
model, metric, dataloader, post_func, device, execution_providers
795799
)
796-
OliveEvaluator.save_sample_log(metric, inference_output, targets, metric.sample_log_num)
800+
OliveEvaluator.save_sample_log(
801+
metric.name, metric.sample_log_dir, inference_output, targets, metric.sample_log_num
802+
)
797803
return OliveEvaluator.compute_accuracy(metric, inference_output, targets)
798804

799805
def _inference_text(
@@ -1490,7 +1496,7 @@ def _evaluate_distributed_accuracy(
14901496
targets = [x for _, t, _ in results for x in t]
14911497
logits = [x for _, _, logit in results for x in logit]
14921498
model_output = OliveModelOutput(preds, logits)
1493-
OliveEvaluator.save_sample_log(metric, model_output, targets, metric.sample_log_num)
1499+
OliveEvaluator.save_sample_log(metric.name, metric.sample_log_dir, model_output, targets, metric.sample_log_num)
14941500
return OliveEvaluator.compute_accuracy(metric, model_output, targets)
14951501

14961502
@staticmethod
@@ -1694,7 +1700,9 @@ def _evaluate_accuracy(
16941700
inference_output, targets = self._inference(
16951701
model, metric, dataloader, post_func, device, execution_providers
16961702
)
1697-
OliveEvaluator.save_sample_log(metric, inference_output, targets, metric.sample_log_num)
1703+
OliveEvaluator.save_sample_log(
1704+
metric.name, metric.sample_log_dir, inference_output, targets, metric.sample_log_num
1705+
)
16981706
return OliveEvaluator.compute_accuracy(metric, inference_output, targets)
16991707

17001708
@torch.no_grad()
@@ -1929,7 +1937,9 @@ def _evaluate_accuracy(
19291937
execution_providers: Optional[Union[str, list[str]]] = None,
19301938
) -> MetricResult:
19311939
inference_output, targets = self._inference(model, metric, dataloader, post_func, device, execution_providers)
1932-
OliveEvaluator.save_sample_log(metric, inference_output, targets, metric.sample_log_num)
1940+
OliveEvaluator.save_sample_log(
1941+
metric.name, metric.sample_log_dir, inference_output, targets, metric.sample_log_num
1942+
)
19331943
return OliveEvaluator.compute_accuracy(metric, inference_output, targets)
19341944

19351945
def _evaluate_raw_latency(
@@ -2004,7 +2014,9 @@ def _evaluate_accuracy(
20042014
execution_providers: Optional[Union[str, list[str]]] = None,
20052015
) -> MetricResult:
20062016
inference_output, targets = self._inference(model, metric, dataloader, post_func, device, execution_providers)
2007-
OliveEvaluator.save_sample_log(metric, inference_output, targets, metric.sample_log_num)
2017+
OliveEvaluator.save_sample_log(
2018+
metric.name, metric.sample_log_dir, inference_output, targets, metric.sample_log_num
2019+
)
20082020
return OliveEvaluator.compute_accuracy(metric, inference_output, targets)
20092021

20102022
def _evaluate_raw_latency(
@@ -2056,6 +2068,76 @@ def __init__(self, tasks: list[str], **kwargs):
20562068
self.ep = kwargs.get("execution_provider")
20572069
self.ep_options = kwargs.get("provider_options")
20582070
self.device = kwargs.get("device")
2071+
# Extra keyword arguments forwarded verbatim to the lm-eval model backend constructor
2072+
# (e.g. ``past_present_share_buffer`` for the ``ortgenai`` backend). Backend-specific;
2073+
# values here override the defaults Olive derives (batch_size, max_length, ep, ...).
2074+
self.model_args = kwargs.get("model_args") or {}
2075+
if not isinstance(self.model_args, dict):
2076+
raise ValueError(f"model_args must be a dict, got {type(self.model_args).__name__}.")
2077+
# When > 0, log the first N per-task sample predictions (question, prediction, target) to a
2078+
# JSONL file for debugging, mirroring the accuracy evaluators' ``sample_log`` behavior.
2079+
self.sample_log_num = kwargs.get("sample_log_num", 0)
2080+
self.sample_log_dir = kwargs.get("sample_log_dir")
2081+
# Chat-template / prompting controls forwarded to lm-eval's ``simple_evaluate``. Instruction-
2082+
# tuned models (e.g. Gemma) score near-random on multiple-choice tasks unless the request is
2083+
# wrapped in their chat template, so allow recipes to opt in. Defaults preserve lm-eval's
2084+
# legacy behavior (no chat template, no system prompt) so existing configs are unaffected.
2085+
# ``apply_chat_template`` may be a bool or, for models with multiple templates, a template name.
2086+
self.apply_chat_template = kwargs.get("apply_chat_template", False)
2087+
self.system_instruction = kwargs.get("system_instruction")
2088+
# Render few-shot examples as separate chat turns instead of one flattened block; only takes
2089+
# effect when ``apply_chat_template`` is enabled (lm-eval rejects it otherwise).
2090+
self.fewshot_as_multiturn = kwargs.get("fewshot_as_multiturn", False)
2091+
# Number of in-context few-shot examples. ``None`` keeps each task's own default (0 for many
2092+
# tasks); set it to match a model card's reported protocol (e.g. 5-shot MMLU).
2093+
self.num_fewshot = kwargs.get("num_fewshot")
2094+
2095+
@staticmethod
2096+
def _extract_prediction(filtered_resps):
2097+
"""Best-effort recovery of the model's prediction from an lm-eval sample record.
2098+
2099+
For multiple-choice tasks ``filtered_resps`` is a list of ``[loglikelihood, is_greedy]``
2100+
pairs (one per choice); the prediction is the argmax choice index. For generation tasks it
2101+
is a list of response strings, which are returned as-is.
2102+
"""
2103+
if not filtered_resps:
2104+
return filtered_resps
2105+
first = filtered_resps[0]
2106+
if isinstance(first, (list, tuple)) and first and isinstance(first[0], (int, float, bool)):
2107+
logliks = [r[0] for r in filtered_resps]
2108+
return int(max(range(len(logliks)), key=logliks.__getitem__))
2109+
return filtered_resps
2110+
2111+
@classmethod
2112+
def _lmeval_samples_to_output(cls, samples: list, num_samples: int) -> tuple["OliveModelOutput", list]:
2113+
"""Convert lm-eval per-task sample records into an ``OliveModelOutput`` + targets.
2114+
2115+
Extracts the question, options and resolved prediction from each record and packs the
2116+
per-sample metadata (``question``, ``options``, ``prediction_index``, ``acc``) into
2117+
``extras`` so the shared :meth:`OliveEvaluator.save_sample_log` writer can persist them.
2118+
"""
2119+
n = min(num_samples, len(samples))
2120+
preds, targets, extras = [], [], []
2121+
for s in samples[:n]:
2122+
doc = s.get("doc") or {}
2123+
extra = {}
2124+
for qk in ("question", "query", "input", "problem"):
2125+
if qk in doc:
2126+
extra["question"] = doc[qk]
2127+
break
2128+
options = doc.get("options") or doc.get("choices")
2129+
if options is not None:
2130+
extra["options"] = options
2131+
pred = cls._extract_prediction(s.get("filtered_resps"))
2132+
if isinstance(pred, int) and isinstance(options, list) and 0 <= pred < len(options):
2133+
extra["prediction_index"] = pred
2134+
pred = f"{chr(ord('A') + pred)}. {options[pred]}"
2135+
if "acc" in s:
2136+
extra["acc"] = s["acc"]
2137+
preds.append(pred)
2138+
targets.append(s.get("target"))
2139+
extras.append(extra)
2140+
return OliveModelOutput(preds=preds, logits=None, extras=extras), targets
20592141

20602142
def evaluate(
20612143
self,
@@ -2125,20 +2207,45 @@ def evaluate(
21252207
)
21262208

21272209
if self.tasks:
2128-
lmmodel = get_model(self.model_class)(**init_args, batch_size=self.batch_size, max_length=self.max_length)
2210+
model_init_args = {
2211+
**init_args,
2212+
"batch_size": self.batch_size,
2213+
"max_length": self.max_length,
2214+
# User-provided model_args win over the Olive-derived defaults above.
2215+
**self.model_args,
2216+
}
2217+
lmmodel = get_model(self.model_class)(**model_init_args)
2218+
2219+
# Keep lm-eval's batching consistent with the backend's effective batch size,
2220+
# which model_args may have overridden.
2221+
effective_batch_size = model_init_args["batch_size"]
21292222

21302223
results = simple_evaluate(
21312224
model=lmmodel,
21322225
tasks=self.tasks,
21332226
task_manager=TaskManager(),
2134-
log_samples=False,
2135-
batch_size=self.batch_size,
2227+
log_samples=self.sample_log_num > 0,
2228+
batch_size=effective_batch_size,
21362229
device=device,
21372230
limit=self.limit,
21382231
# Forward the configured value instead of letting lm-eval silently use its default.
21392232
bootstrap_iters=self.bootstrap_iters,
2233+
# Wrap requests in the model's chat template / system prompt when requested so
2234+
# instruction-tuned models are evaluated the way they are served.
2235+
apply_chat_template=self.apply_chat_template,
2236+
system_instruction=self.system_instruction,
2237+
fewshot_as_multiturn=self.fewshot_as_multiturn,
2238+
num_fewshot=self.num_fewshot,
21402239
)
21412240

2241+
if self.sample_log_num > 0:
2242+
sample_dir = Path(self.sample_log_dir) if self.sample_log_dir else Path.cwd() / "sample_logs"
2243+
for task_name, task_samples in (results.get("samples") or {}).items():
2244+
if not task_samples:
2245+
continue
2246+
output, targets = self._lmeval_samples_to_output(task_samples, self.sample_log_num)
2247+
OliveEvaluator.save_sample_log(task_name, sample_dir, output, targets, self.sample_log_num)
2248+
21422249
for task_name in sorted(results["results"].keys()):
21432250
metric_items = sorted(results["results"][task_name].items())
21442251

test/evaluator/test_lmeval_ort.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,32 @@ class _Holder:
4040

4141
device_prop.fset(holder, "cpu")
4242
assert device_prop.fget(holder) == "cpu"
43+
44+
45+
class TestResolvePastPresentShareBuffer:
46+
"""The exported genai_config value is the default; an explicit override wins."""
47+
48+
@staticmethod
49+
def _resolve(override, genai_config):
50+
from olive.evaluator.lmeval_ort import LMEvalORTGenAIEvaluator
51+
52+
# pylint: disable=protected-access
53+
return LMEvalORTGenAIEvaluator._resolve_past_present_share_buffer(override, genai_config)
54+
55+
@pytest.mark.parametrize("config_value", [True, False])
56+
def test_uses_config_value_when_no_override(self, config_value):
57+
genai_config = {"search": {"past_present_share_buffer": config_value}}
58+
assert self._resolve(None, genai_config) is config_value
59+
60+
def test_defaults_to_false_when_absent(self):
61+
assert self._resolve(None, {"search": {}}) is False
62+
assert self._resolve(None, {}) is False
63+
64+
@pytest.mark.parametrize(
65+
("override", "config_value"),
66+
[(False, True), (True, False)],
67+
)
68+
def test_override_takes_precedence_over_config(self, override, config_value):
69+
# Gemma 4 exports with shared buffers enabled but requires it disabled for evaluation.
70+
genai_config = {"search": {"past_present_share_buffer": config_value}}
71+
assert self._resolve(override, genai_config) is override

0 commit comments

Comments
 (0)