|
|
|
''' |
|
Simplistic tool call benchmarks for llama-server and ollama. |
|
|
|
Essentially runs the tests at server/examples/server/tests/unit/test_tool_call.py N times, at different temperatures and on different backends (current llama-server, baseline llama-server and ollama), |
|
and plots the results of multiple runs (from same .jsonl file or multiple ones) as a success rate heatmap. |
|
|
|
Simple usage example: |
|
|
|
cmake -B build -DLLAMA_CURL=1 && cmake --build build --config Release -j -t llama-server |
|
|
|
export LLAMA_SERVER_BIN_PATH=$PWD/build/bin/llama-server |
|
export LLAMA_CACHE=${LLAMA_CACHE:-$HOME/Library/Caches/llama.cpp} |
|
|
|
./scripts/tool_bench.py run --n 30 --temp -1 --temp 0 --temp 1 --model "Qwen 2.5 1.5B Q4_K_M" --output qwen1.5b.jsonl --hf bartowski/Qwen2.5-1.5B-Instruct-GGUF --ollama qwen2.5:1.5b-instruct-q4_K_M |
|
./scripts/tool_bench.py run --n 30 --temp -1 --temp 0 --temp 1 --model "Qwen 2.5 Coder 7B Q4_K_M" --output qwenc7b.jsonl --hf bartowski/Qwen2.5-Coder-7B-Instruct-GGUF --ollama qwen2.5-coder:7b |
|
|
|
./scripts/tool_bench.py plot *.jsonl # Opens window w/ heatmap |
|
./scripts/tool_bench.py plot qwen*.jsonl --output qwen.png # Saves heatmap to qwen.png |
|
|
|
(please see ./scripts/tool_bench.sh for a more complete example) |
|
''' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from contextlib import contextmanager |
|
from pathlib import Path |
|
import re |
|
from statistics import mean, median |
|
from typing import Annotated, Dict, List, Optional, Tuple |
|
import atexit |
|
import json |
|
import logging |
|
import matplotlib.pyplot as plt |
|
import numpy as np |
|
import pandas as pd |
|
import seaborn as sns |
|
import subprocess |
|
import sys |
|
import time |
|
import typer |
|
|
|
sys.path.insert(0, Path(__file__).parent.parent.as_posix()) |
|
if True: |
|
from examples.server.tests.utils import ServerProcess |
|
from examples.server.tests.unit.test_tool_call import TIMEOUT_SERVER_START, do_test_calc_result, do_test_hello_world, do_test_weather |
|
|
|
|
|
@contextmanager |
|
def scoped_server(sp: ServerProcess): |
|
def stop(): |
|
nonlocal sp |
|
if sp is not None: |
|
sp.stop() |
|
sp = None |
|
atexit.register(stop) |
|
yield sp |
|
stop() |
|
|
|
|
|
logging.basicConfig( |
|
level=logging.INFO, |
|
format='%(asctime)s - %(levelname)s - %(message)s' |
|
) |
|
logger = logging.getLogger(__name__) |
|
|
|
app = typer.Typer() |
|
|
|
|
|
@app.command() |
|
def plot(files: List[Path], output: Optional[Path] = None, test_regex: Optional[str] = None, server_regex: Optional[str] = None): |
|
|
|
lines: List[Dict] = [] |
|
for file in files: |
|
if not file.exists(): |
|
logger.error(f"File not found: {file}") |
|
continue |
|
|
|
try: |
|
with file.open() as f: |
|
raw_data = f.read() |
|
logger.info(f"Reading {file} ({len(raw_data)} bytes)") |
|
|
|
for line_num, line in enumerate(raw_data.split('\n'), 1): |
|
line = line.strip() |
|
if not line: |
|
continue |
|
try: |
|
record = json.loads(line) |
|
lines.append(record) |
|
except json.JSONDecodeError as e: |
|
logger.warning(f"Invalid JSON at {file}:{line_num} - {e}") |
|
except Exception as e: |
|
logger.error(f"Error processing {file}: {e}") |
|
|
|
if not lines: |
|
raise Exception("No valid data was loaded") |
|
|
|
data_dict: Dict[Tuple, float] = {} |
|
models: List[str] = [] |
|
temps = set() |
|
tests = set() |
|
server_names = set() |
|
total_counts = set() |
|
for rec in lines: |
|
try: |
|
model = rec["model"] |
|
temp = rec["temp"] |
|
server_name = rec["server_name"] |
|
test = rec["test"] |
|
success = rec["success_ratio"] |
|
success_count = rec["success_count"] |
|
failure_count = rec["failure_count"] |
|
total_count = success_count + failure_count |
|
total_counts.add(total_count) |
|
|
|
if test_regex and not re.search(test_regex, test): |
|
continue |
|
|
|
if server_regex and not re.search(server_regex, server_name): |
|
continue |
|
|
|
data_dict[(model, temp, server_name, test)] = success |
|
|
|
if model not in models: |
|
models.append(model) |
|
temps.add(temp) |
|
tests.add(test) |
|
server_names.add(server_name) |
|
|
|
except KeyError as e: |
|
logger.warning(f"Missing required field in record: {e}") |
|
|
|
if len(total_counts) > 1: |
|
logger.warning(f"Total counts are not consistent: {total_counts}") |
|
|
|
|
|
temps = list(sorted(temps, key=lambda x: x if x is not None else -1)) |
|
tests = list(sorted(tests)) |
|
server_names = list(sorted(server_names)) |
|
|
|
logger.info(f"Processed {len(lines)} lines") |
|
logger.info(f"Found {len(data_dict)} valid data points") |
|
logger.info(f"Models: {models}") |
|
logger.info(f"Temperatures: {temps}") |
|
logger.info(f"Tests: {tests}") |
|
logger.info(f"Servers: {server_names}") |
|
|
|
matrix: list[list[float]] = [] |
|
index: list[str] = [] |
|
|
|
all_cols = [ |
|
(server_name, test) |
|
for server_name in server_names |
|
for test in tests |
|
] |
|
for model in models: |
|
for temp in temps: |
|
index.append(f"{model} @ {temp}") |
|
row_vals = [ |
|
data_dict.get((model, temp, server_name, test), np.nan) |
|
for server_name, test in all_cols |
|
] |
|
matrix.append(row_vals) |
|
|
|
columns: list[str] = [f"{server_name}\n{test}" for server_name, test in all_cols] |
|
|
|
df = pd.DataFrame(matrix, index=np.array(index), columns=np.array(columns)) |
|
|
|
plt.figure(figsize=(12, 6)) |
|
|
|
sns.heatmap( |
|
df, annot=True, cmap="RdYlGn", vmin=0.0, vmax=1.0, cbar=True, fmt=".2f", center=0.5, square=True, linewidths=0.5, |
|
cbar_kws={"label": "Success Ratio"}, |
|
) |
|
|
|
plt.title(f"Tool Call Bench (n = {str(min(total_counts)) if len(total_counts) == 1 else f'{min(total_counts)}-{max(total_counts)}'})\nSuccess Ratios by Server & Test", pad=20) |
|
plt.xlabel("Server & Test", labelpad=10) |
|
plt.ylabel("Model @ Temperature", labelpad=10) |
|
|
|
plt.xticks(rotation=45, ha='right') |
|
plt.yticks(rotation=0) |
|
|
|
plt.tight_layout() |
|
|
|
if output: |
|
plt.savefig(output, dpi=300, bbox_inches='tight') |
|
logger.info(f"Plot saved to {output}") |
|
else: |
|
plt.show() |
|
|
|
|
|
@app.command() |
|
def run( |
|
output: Annotated[Path, typer.Option(help="Output JSON file")], |
|
model: Annotated[Optional[str], typer.Option(help="Name of the model to test (server agnostic)")] = None, |
|
hf: Annotated[Optional[str], typer.Option(help="GGUF huggingface model repo id (+ optional quant) to test w/ llama-server")] = None, |
|
chat_template: Annotated[Optional[str], typer.Option(help="Chat template override for llama-server")] = None, |
|
ollama: Annotated[Optional[str], typer.Option(help="Ollama model tag to test")] = None, |
|
llama_baseline: Annotated[Optional[str], typer.Option(help="llama-server baseline binary path to use as baseline")] = None, |
|
n: Annotated[int, typer.Option(help="Number of times to run each test")] = 10, |
|
temp: Annotated[Optional[List[float]], typer.Option(help="Set of temperatures to test")] = None, |
|
top_p: Annotated[Optional[float], typer.Option(help="top_p")] = None, |
|
top_k: Annotated[Optional[int], typer.Option(help="top_k")] = None, |
|
ctk: Annotated[Optional[str], typer.Option(help="ctk")] = None, |
|
ctv: Annotated[Optional[str], typer.Option(help="ctv")] = None, |
|
fa: Annotated[Optional[bool], typer.Option(help="fa")] = None, |
|
seed: Annotated[Optional[int], typer.Option(help="Random seed")] = None, |
|
port: Annotated[int, typer.Option(help="llama-server port")] = 8084, |
|
force: Annotated[bool, typer.Option(help="Force overwrite of output file")] = False, |
|
append: Annotated[bool, typer.Option(help="Append to output file")] = False, |
|
|
|
test_hello_world: Annotated[bool, typer.Option(help="Whether to run the hello world test")] = True, |
|
test_weather: Annotated[bool, typer.Option(help="Whether to run the weather test")] = True, |
|
test_calc_result: Annotated[bool, typer.Option(help="Whether to run the calc result test")] = False, |
|
): |
|
|
|
|
|
n_predict = 512 |
|
|
|
n_ctx = 2048 |
|
|
|
assert force or append or not output.exists(), f"Output file already exists: {output}; use --force to overwrite" |
|
|
|
with output.open('a' if append else 'w') as output_file: |
|
|
|
def run(server: ServerProcess, *, server_name: str, model_id: str, temp: Optional[float] = None, output_kwargs={}, request_kwargs={}): |
|
request_kwargs = {**request_kwargs} |
|
if temp is not None: |
|
request_kwargs['temperature'] = temp |
|
if top_p is not None: |
|
request_kwargs['top_p'] = top_p |
|
if top_k is not None: |
|
request_kwargs['top_k'] = top_k |
|
if seed is not None: |
|
request_kwargs['seed'] = seed |
|
|
|
request_kwargs['cache_prompt'] = False |
|
|
|
tests = {} |
|
if test_hello_world: |
|
tests["hello world"] = lambda server: do_test_hello_world(server, **request_kwargs) |
|
if test_weather: |
|
tests["weather"] = lambda server: do_test_weather(server, **request_kwargs) |
|
if test_calc_result: |
|
tests["calc result"] = lambda server: do_test_calc_result(server, None, 512, **request_kwargs) |
|
|
|
for test_name, test in tests.items(): |
|
success_count = 0 |
|
failure_count = 0 |
|
failures = [] |
|
success_times = [] |
|
failure_times = [] |
|
logger.info(f"Running {test_name} ({server_name}, {model}): ") |
|
for i in range(n): |
|
start_time = time.time() |
|
|
|
def elapsed(): |
|
return time.time() - start_time |
|
|
|
try: |
|
test(server) |
|
success_times.append(elapsed()) |
|
success_count += 1 |
|
logger.info('success') |
|
except Exception as e: |
|
logger.error(f'failure: {e}') |
|
failure_count += 1 |
|
failure_times.append(elapsed()) |
|
failures.append(str(e)) |
|
|
|
|
|
output_file.write(json.dumps({**output_kwargs, **dict( |
|
model=model, |
|
server_name=server_name, |
|
model_id=model_id, |
|
test=test_name, |
|
temp=t, |
|
top_p=top_p, |
|
top_k=top_k, |
|
ctk=ctk, |
|
ctv=ctv, |
|
seed=seed, |
|
success_ratio=float(success_count) / n, |
|
avg_time=mean(success_times + failure_times), |
|
median_time=median(success_times + failure_times), |
|
success_count=success_count, |
|
success_times=success_times, |
|
failure_count=failure_count, |
|
failure_times=failure_times, |
|
failures=list(set(failures)), |
|
)}) + '\n') |
|
output_file.flush() |
|
|
|
for t in [None] if temp is None else [t if t >= 0 else None for t in temp]: |
|
if hf is not None: |
|
|
|
servers: list[Tuple[str, Optional[str]]] = [('llama-server', None)] |
|
if llama_baseline is not None: |
|
servers.append(('llama-server (baseline)', llama_baseline)) |
|
|
|
for server_name, server_path in servers: |
|
server = ServerProcess() |
|
server.n_ctx = n_ctx |
|
server.n_slots = 1 |
|
server.jinja = True |
|
server.ctk = ctk |
|
server.ctv = ctv |
|
server.fa = fa |
|
server.n_predict = n_predict |
|
server.model_hf_repo = hf |
|
server.model_hf_file = None |
|
server.chat_template = chat_template |
|
server.server_path = server_path |
|
if port is not None: |
|
server.server_port = port |
|
|
|
|
|
with scoped_server(server): |
|
server.start(timeout_seconds=TIMEOUT_SERVER_START) |
|
for ignore_chat_grammar in [False]: |
|
run( |
|
server, |
|
server_name=server_name, |
|
model_id=hf, |
|
temp=t, |
|
output_kwargs=dict( |
|
chat_template=chat_template, |
|
), |
|
request_kwargs=dict( |
|
ignore_chat_grammar=ignore_chat_grammar, |
|
), |
|
) |
|
|
|
if ollama is not None: |
|
server = ServerProcess() |
|
server.server_port = 11434 |
|
server.server_host = "localhost" |
|
subprocess.check_call(["ollama", "pull", ollama]) |
|
|
|
with scoped_server(server): |
|
run( |
|
server, |
|
server_name="ollama", |
|
model_id=ollama, |
|
temp=t, |
|
output_kwargs=dict( |
|
chat_template=None, |
|
), |
|
request_kwargs=dict( |
|
model=ollama, |
|
max_tokens=n_predict, |
|
num_ctx = n_ctx, |
|
), |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
app() |
|
|