Inside a General-Purpose LLM Evaluation Framework
Inside a General-Purpose LLM Evaluation Framework
Translated from Chinese with AI · Read the original
I mainly examined OpenCompass and LightEval. Their overall logic is similar, centered on models and datasets.
LightEval
A typical command:
lighteval accelerate \ "model_name=openai-community/gpt2,batch_size=1" \ hellaswag1. Select the Evaluation Backend

2. Using Accelerate as an Example, Build a Complete Evaluation Pipeline
pipeline = Pipeline( tasks=tasks, pipeline_parameters=pipeline_params, evaluation_tracker=evaluation_tracker, model_config=model_config,)It includes:
- Tasks: provide inference requirements such as datasets, models, and evaluation rules;
- EvaluationTracker: tracking information, integration with external hubs, result formatting, and so on;
3. Build the Dataset
Tasks specify datasets. In the hellaswag task, the hf_xx attributes identify the dataset and its splits.
The Metrics.exact_match setting specifies how predictions are compared with targets: exact matching here.
hellaswag = LightevalTaskConfig( name="hellaswag", prompt_function=hellaswag_prompt, hf_repo="Rowan/hellaswag", hf_subset="default", hf_avail_splits=["train", "test", "validation"], evaluation_splits=["validation"], few_shots_split=None, few_shots_select=None, generation_size=1, metrics=[ Metrics.exact_match, ], stop_sequence=["\n"], version=0,)In the pipeline, extract the dataset’s validation portion and split it according to sampling_method.
sampling_method, how we query/sample information from the model, has three forms:
- GENERATIVE: Sample text from the distribution
- LOGPROBS: Sample/retrieve probability scores for choices
- PERPLEXITY: Sample/retrieve probability scores across sequences
4. Evaluate the Model
Take SamplingMethod.GENERATIVE as an example:
case SamplingMethod.GENERATIVE: model_outputs = self.model.greedy_until(docs) outputs[sampling_method] = model_outputsThis also has several steps:
- Divide the dataset into several splits, fine enough to track inference progress
- Construct a Torch DataLoader
- Preprocess data and prompts using the model’s tokenizer
- Run inference and return responses
5. Compute Metrics
For the earlier hellaswag example, the metric is exact_match. The key method is:
@abstractmethoddef compute(self, doc: Doc, model_response: ModelResponse, **kwargs): raise NotImplementedError- doc: contains the original gold/target text;
- model_response: the model’s inference output, or prediction;
Compare after normalization. Metrics use different comparison methods, then collect and aggregate results through aggregation_function.
Here is the implementation of exact_match:
def compute(self, doc: Doc, model_response: ModelResponse, **kwargs) -> float: """Computes the metric over a list of golds and predictions for one single sample.
Args: doc (Doc): The document containing gold references. model_response (ModelResponse): The model's response containing predictions. **kwargs: Additional keyword arguments.
Returns: float: Aggregated score over the current sample's items. """ results = [] # We might need to flatten golds if they are a list of lists golds = doc.get_golds() for gold in golds: for pred in model_response.final_text: results.append(self.compute_one_item(gold=gold, pred=pred)) return self.aggregation_function(results)OpenCompass
OpenCompass is similar: users configure a model and dataset. For example:
opencompass --models hf_qwen2_0_5b_instruct_cpu --datasets demo_gsm8k_chat_gen- model: a small Qwen model
- dataset: GSM8K
1. Partition the Dataset
partitioner = PARTITIONERS.build(cfg.infer.partitioner)tasks = partitioner(cfg)Users can specify a Partitioner; by default, splitting follows max_number_workers. Each Partitioner
constructs task configurations from its partitions and returns a list, for example NumWorkerPartitioner:
for dataset in chunks: tasks.append( Config({ 'models': [model], 'datasets': [[dataset]], 'work_dir': work_dir, **add_cfg }))2. Construct and Execute the Runner
runner = RUNNERS.build(cfg.infer.runner)runner(tasks)Initialize a runner with task configurations and invoke launch. For example, LocalRunner uses
multiple processes for parallel execution, unlike the LightEval flow above:
pbar = tqdm(total=len(tasks))lock = Lock()
def submit(task, index): task = TASKS.build(dict(cfg=task, type=self.task_cfg['type'])) num_gpus = task.num_gpus assert len(gpus) >= num_gpus
while True: lock.acquire() if sum(gpus > 0) >= num_gpus: gpu_ids = np.where(gpus)[0][:num_gpus] gpus[gpu_ids] -= 1 lock.release() break lock.release() time.sleep(1)
res = self._launch(task, gpu_ids, index) pbar.update()
with lock: gpus[gpu_ids] += 1
return res
with ThreadPoolExecutor( max_workers=self.max_num_workers) as executor: status = executor.map(submit, tasks, range(len(tasks)))Because execution is parallel, GPU resource records are locked, and ThreadPoolExecutor submits work concurrently.
3. Evaluation
OpenCompass evaluation differs from LightEval in these ways:
- OpenCompass designs evaluation with the same Runner/Task architecture as inference. One benefit is that users can run evaluation separately;
- LightEval integrates evaluation into its pipeline, so it cannot run independently;
The overall flow resembles LightEval, with preprocessing, scoring, and saving.