Skip to content

Commit 48909b6

Browse files
jiafatomCopilot
andauthored
Add vision genai inference path for multi-file VLM evaluation (microsoft#2488)
## Describe your changes Adds `_inference_vision_genai` method to `OnnxEvaluator` that enables `olive run` to evaluate multi-file ONNX vision-language models (e.g., Qwen3-VL) using `onnxruntime-genai`. ### Problem Vision-language models exported via `onnxruntime-genai` produce multiple ONNX files (`vision.onnx`, `text.onnx`, `embedding.onnx`) with a `genai_config.json`. The existing `_inference_vision` method only supports single-file ONNX models with classification-style forward pass. This prevented using `olive run --config` for evaluation of autoregressive VLMs. ### Solution - Auto-detect genai vision models by checking if `genai_config.json` contains a `vision` field - Route to new `_inference_vision_genai` method which uses `og.Model`, `multimodal_processor`, and `og.Generator` for autoregressive text generation - Follows the same pattern as the existing speech genai inference (`_inference_text_genai` for Whisper, `_inference_text_genai_streaming` for Nemotron) - Falls back to existing `_inference_vision` for single-file ONNX VQA models ### Usage ```json { "input_model": { "type": "OnnxModel", "model_path": "path/to/models", "onnx_file_name": "text.onnx" } } ``` The evaluator will auto-detect `genai_config.json` in the model directory and use the genai path. ### Related - Depends on: microsoft#2476 (vision metrics: exact_match, relaxed_accuracy, word_sort_ratio) - Tested with: Qwen3-VL-2B-Instruct on AI2D benchmark in olive-recipes --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8c5b096 commit 48909b6

4 files changed

Lines changed: 359 additions & 19 deletions

File tree

olive/data/component/pre_process_data.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ def vision_vqa_pre_process(
387387
image_col: str = "image",
388388
question_col: str = "question",
389389
answer_col: str = "answer",
390+
options_col: str = "",
391+
system_prompt: str = "",
390392
max_samples: Optional[int] = None,
391393
limit: Optional[float] = None,
392394
seed: int = 42,
@@ -408,6 +410,10 @@ def vision_vqa_pre_process(
408410
image_col: Name of the image column. Defaults to "image".
409411
question_col: Name of the question column. Defaults to "question".
410412
answer_col: Name of the answer column. Defaults to "answer".
413+
options_col: Name of the options column for multiple-choice questions. If specified,
414+
options are formatted as numbered choices and appended to the question. Defaults to "".
415+
system_prompt: System prompt to guide model responses (e.g., "Reply with only the
416+
option number"). Passed through to the evaluator. Defaults to "".
411417
max_samples: Maximum number of samples (deprecated, use limit). Defaults to None.
412418
limit: Sampling limit following Olive convention:
413419
If >= 1: use first N samples.
@@ -438,11 +444,13 @@ class VisionVQADataset:
438444
Note: Use batch_size=1 in dataloader config as images have variable sizes.
439445
"""
440446

441-
def __init__(self, hf_dataset, image_column, question_column, answer_column):
447+
def __init__(self, hf_dataset, image_column, question_column, answer_column, options_column="", sys_prompt=""):
442448
self.dataset = hf_dataset
443449
self.image_column = image_column
444450
self.question_column = question_column
445451
self.answer_column = answer_column
452+
self.options_column = options_column
453+
self.system_prompt = sys_prompt
446454

447455
def __len__(self):
448456
return len(self.dataset)
@@ -452,11 +460,27 @@ def __getitem__(self, idx):
452460
image = item[self.image_column]
453461
question = item[self.question_column]
454462
answer = item[self.answer_column]
463+
464+
# Format options into the question if options_col is specified
465+
has_options = False
466+
if self.options_column and self.options_column in item:
467+
options = item[self.options_column]
468+
if isinstance(options, (list, tuple)):
469+
options_text = "\n".join(f"{i}. {opt}" for i, opt in enumerate(options))
470+
question = f"{question}\n{options_text}"
471+
has_options = True
472+
455473
# Handle list/tuple answers (some datasets have multiple valid answers)
456474
# Join with | separator so metrics can match against any valid answer
457475
if isinstance(answer, (list, tuple)):
458476
answer = "|".join(str(a) for a in answer) if answer else ""
459-
return {"image": image, "question": question}, str(answer)
477+
input_dict = {
478+
"image": image,
479+
"question": question,
480+
"system_prompt": self.system_prompt,
481+
"extract_number": has_options,
482+
}
483+
return input_dict, str(answer)
460484

461485
@staticmethod
462486
def collate_fn(batch):
@@ -472,4 +496,4 @@ def collate_fn(batch):
472496
answers = [item[1] for item in batch]
473497
return (inputs, answers)
474498

475-
return VisionVQADataset(dataset, image_col, question_col, answer_col)
499+
return VisionVQADataset(dataset, image_col, question_col, answer_col, options_col, system_prompt)

olive/data/config.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,11 @@ def to_data_container(self) -> "DataContainer":
213213
return dc_cls(config=self)
214214

215215
def _update_default_component_type_with_task_type(self, dc_cls, default_components_type):
216-
for component_name, config in self.components.items():
216+
for config in self.components.values():
217217
if config and config.params:
218218
task_type = config.params.get("task")
219219
if task_type:
220-
task_specific_override = dc_cls.task_type_components_map.get(
221-
task_type.replace("-with-past", ""), {}
222-
).get(component_name)
223-
if task_specific_override:
224-
default_components_type[component_name] = task_specific_override
220+
task_overrides = dc_cls.task_type_components_map.get(task_type.replace("-with-past", ""), {})
221+
# Apply all component overrides for this task type
222+
for override_component, override_type in task_overrides.items():
223+
default_components_type[override_component] = override_type

olive/evaluator/olive_evaluator.py

Lines changed: 163 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,20 @@ def _inference(
582582
dump_tuning_result(session.session, tuning_result_file)
583583
return OliveModelOutput(preds=preds, logits=logits), targets
584584

585+
@staticmethod
586+
def _load_genai_config(model: ONNXModelHandler) -> Optional[dict]:
587+
"""Load genai_config.json from the model directory, or return None if not found."""
588+
genai_config_path = Path(model.model_path).parent / "genai_config.json"
589+
if not genai_config_path.exists():
590+
return None
591+
import json
592+
593+
try:
594+
with genai_config_path.open(encoding="utf-8") as f:
595+
return json.load(f)
596+
except json.JSONDecodeError as e:
597+
raise ValueError(f"Invalid JSON in genai config file: {genai_config_path}") from e
598+
585599
def _evaluate_onnx_accuracy(
586600
self,
587601
model: ONNXModelHandler,
@@ -593,18 +607,21 @@ def _evaluate_onnx_accuracy(
593607
) -> MetricResult:
594608
if _is_vision_metric(metric):
595609
_validate_vision_task_metric(metric)
596-
inference_output, targets = self._inference_vision(
597-
model, metric, dataloader, post_func, device, execution_providers
598-
)
610+
# Auto-detect genai vision model by checking for genai_config.json with vision field
611+
genai_cfg = self._load_genai_config(model)
612+
use_genai_vision = genai_cfg is not None and "vision" in genai_cfg.get("model", {})
613+
614+
if use_genai_vision:
615+
inference_output, targets = self._inference_vision_genai(model, dataloader, device)
616+
else:
617+
inference_output, targets = self._inference_vision(
618+
model, metric, dataloader, post_func, device, execution_providers
619+
)
599620
elif _is_text_based_metric(metric):
600621
# Auto-detect genai model by checking for genai_config.json
601-
genai_config_path = Path(model.model_path).parent / "genai_config.json"
602-
if genai_config_path.exists():
603-
import json
604-
605-
with genai_config_path.open() as f:
606-
genai_config = json.load(f)
607-
model_type = genai_config.get("model", {}).get("type", "")
622+
genai_cfg = self._load_genai_config(model)
623+
if genai_cfg:
624+
model_type = genai_cfg.get("model", {}).get("type", "")
608625

609626
if model_type == "whisper":
610627
inference_output, targets = self._inference_text_genai(
@@ -795,6 +812,142 @@ def _inference_vision(
795812

796813
return OliveModelOutput(preds=all_preds, logits=None), all_targets
797814

815+
def _inference_vision_genai(
816+
self,
817+
model: ONNXModelHandler,
818+
dataloader: "DataLoader",
819+
device: Device = Device.CPU,
820+
) -> tuple[OliveModelOutput, Any]:
821+
"""Vision-based inference for VQA/OCR metrics using onnxruntime-genai.
822+
823+
Auto-detected when the model directory contains genai_config.json with a vision field.
824+
Uses og.Model with multimodal processor for vision-language models (e.g., Qwen3-VL).
825+
The dataloader must yield (input_dict, labels) where input_dict contains
826+
'image' (PIL Image) and 'question' (str), and labels are reference answer strings.
827+
828+
Note: GPU/CPU selection is driven by the `device` parameter. onnxruntime-genai uses
829+
short provider names internally (e.g., "cuda") which differ from ORT-style EP names.
830+
"""
831+
try:
832+
import onnxruntime_genai as og
833+
except ImportError as e:
834+
raise ImportError(
835+
"onnxruntime-genai is required for genai-based vision evaluation. "
836+
"Install it with: pip install onnxruntime-genai"
837+
) from e
838+
839+
import json
840+
import re
841+
import tempfile
842+
843+
try:
844+
from PIL import Image
845+
except ImportError as e:
846+
raise ImportError("Pillow is required for vision evaluation. Install it with: pip install Pillow") from e
847+
848+
model_dir = str(Path(model.model_path).parent)
849+
850+
# max_length in genai is total sequence length (input + output).
851+
# Default to 1028 which accommodates image/prompt tokens (~200-500) plus answer tokens.
852+
# Note: genai_config.json's search.max_length is typically the full context window
853+
# (e.g., 262144) which is too large — the model will stop at EOS well before this cap.
854+
max_length = 1028
855+
856+
# Build og.Model with appropriate execution provider
857+
# Note: onnxruntime-genai uses CPU by default when no provider is appended.
858+
# Only non-CPU providers need to be explicitly added using short names (e.g., "cuda").
859+
# This follows the same pattern as _inference_text_genai and _inference_text_genai_streaming.
860+
config = og.Config(model_dir)
861+
config.clear_providers()
862+
if device == Device.GPU:
863+
config.append_provider("cuda")
864+
og_model = og.Model(config)
865+
processor = og_model.create_multimodal_processor()
866+
tokenizer = og.Tokenizer(og_model)
867+
868+
all_preds = []
869+
all_targets = []
870+
871+
# Use a temporary directory for image files to avoid per-file create/delete overhead
872+
with tempfile.TemporaryDirectory() as tmp_dir:
873+
tmp_img_path = Path(tmp_dir) / "input.png"
874+
875+
for batch in dataloader:
876+
input_data, labels = OliveEvaluator.unpack_batch_for_accuracy(batch)
877+
878+
# input_data is a dict with 'image' (PIL) and 'question' (str)
879+
# or a list of such dicts for batch_size > 1
880+
items = [input_data] if isinstance(input_data, dict) else input_data
881+
882+
for item in items:
883+
pil_image = item.get("image")
884+
question = item.get("question", "")
885+
sys_prompt = item.get("system_prompt", "")
886+
extract_number = item.get("extract_number", False)
887+
888+
if pil_image is None:
889+
# Append empty pred to maintain alignment with targets
890+
all_preds.append("")
891+
continue
892+
893+
# Ensure PIL Image
894+
if not isinstance(pil_image, Image.Image):
895+
with Image.open(pil_image) as img:
896+
pil_image = img.convert("RGB")
897+
898+
# Build chat messages for the vision-language model
899+
messages = []
900+
if sys_prompt:
901+
messages.append({"role": "system", "content": sys_prompt})
902+
messages.append(
903+
{
904+
"role": "user",
905+
"content": [
906+
{"type": "image"},
907+
{"type": "text", "text": question},
908+
],
909+
}
910+
)
911+
messages_json = json.dumps(messages)
912+
913+
# Save image to temp file for og.Images (reuse same path to minimize I/O)
914+
pil_image.save(str(tmp_img_path), format="PNG")
915+
images = og.Images.open(str(tmp_img_path))
916+
917+
prompt = tokenizer.apply_chat_template(messages_json, add_generation_prompt=True)
918+
inputs = processor(prompt, images=images)
919+
920+
params = og.GeneratorParams(og_model)
921+
params.set_search_options(max_length=max_length, do_sample=False)
922+
923+
generator = og.Generator(og_model, params)
924+
generator.set_inputs(inputs)
925+
926+
tokens = []
927+
while not generator.is_done():
928+
generator.generate_next_token()
929+
tokens.append(generator.get_next_tokens()[0])
930+
del generator
931+
932+
pred = tokenizer.decode(tokens).strip()
933+
# For multiple-choice tasks, extract leading number from responses
934+
# like "1. D" or "0. krill" to match the expected answer format
935+
if extract_number:
936+
num_match = re.match(r"^(\d+)", pred)
937+
if num_match:
938+
pred = num_match.group(1)
939+
all_preds.append(pred)
940+
941+
# Collect reference texts (aligned with preds including empty ones for None images)
942+
if isinstance(labels, (list, tuple)):
943+
all_targets.extend(labels)
944+
else:
945+
all_targets.append(labels)
946+
947+
del og_model
948+
949+
return OliveModelOutput(preds=all_preds, logits=None), all_targets
950+
798951
def _inference_text_genai(
799952
self,
800953
model: ONNXModelHandler,

0 commit comments

Comments
 (0)