From c97b74770c85f0b16dd57ca6bfb2a304ac1ee8b5 Mon Sep 17 00:00:00 2001 From: Abhishekpandey123-git Date: Tue, 27 Oct 2026 21:26:50 +0530 Subject: [PATCH] feat: add explicit llama.cpp provider and benchmark tooling --- app/api/routes/llm_config.py | 61 +++---- app/api/schemas/enums.py | 1 + app/core/config.py | 1 + app/services/llm/specs.py | 4 + app/services/llm/user_config.py | 13 +- benchmark/.env.example | 11 ++ .../LLAMA_CPP_COMPARISON_REPORT_TEMPLATE.md | 91 ++++++++++ benchmark/README.md | 88 ++++++++-- benchmark/compare_benchmark_runs.py | 137 +++++++++++++++ benchmark/profile_runtime.py | 160 ++++++++++++++++++ benchmark/run.py | 86 +++++++--- benchmark/run_info.py | 29 +++- benchmark/summarize_runtime.py | 60 +++++++ benchmark/tests/conftest.py | 7 + .../tests/test_compare_benchmark_runs.py | 78 +++++++++ benchmark/tests/test_profile_runtime.py | 27 +++ benchmark/tests/test_summarize_runtime.py | 40 +++++ contracts/schemas/enums.yaml | 3 +- docker/.env.example | 13 +- docs/multi-llm-support/configuration.md | 15 +- tests/test_llm_registry.py | 12 ++ tests/test_llm_service.py | 9 + 22 files changed, 865 insertions(+), 81 deletions(-) create mode 100644 benchmark/LLAMA_CPP_COMPARISON_REPORT_TEMPLATE.md create mode 100644 benchmark/compare_benchmark_runs.py create mode 100644 benchmark/profile_runtime.py create mode 100644 benchmark/summarize_runtime.py create mode 100644 benchmark/tests/conftest.py create mode 100644 benchmark/tests/test_compare_benchmark_runs.py create mode 100644 benchmark/tests/test_profile_runtime.py create mode 100644 benchmark/tests/test_summarize_runtime.py diff --git a/app/api/routes/llm_config.py b/app/api/routes/llm_config.py index d4e2625..eb7b73b 100644 --- a/app/api/routes/llm_config.py +++ b/app/api/routes/llm_config.py @@ -62,9 +62,9 @@ def get_provider_configs(db: Session = Depends(get_db)): ui_key = bool( record and record.api_key_encrypted and has_decryptable_credential(provider.value) ) - ui_custom_url = bool(provider is LLMProvider.custom and record and record.base_url) - source = "ui" if ui_key or ui_custom_url else "env" if env_key or env.get("BASE_URL") else "none" - base_url = (record.base_url if ui_custom_url else None) or env.get("BASE_URL") or spec.default_base_url + ui_compatible_url = bool(provider in {LLMProvider.custom, LLMProvider.llama_cpp} and record and record.base_url) + source = "ui" if ui_key or ui_compatible_url else "env" if env_key or env.get("BASE_URL") else "none" + base_url = (record.base_url if ui_compatible_url else None) or env.get("BASE_URL") or spec.default_base_url key_hint = record.key_hint if ui_key else env_key[-4:] if env_key else None statuses.append( LLMProviderCredentialStatus( @@ -77,10 +77,10 @@ def get_provider_configs(db: Session = Depends(get_db)): credential_status=( "available" if has_decryptable_credential(provider.value) else "unavailable" ) if record and record.api_key_encrypted else None, - base_url=_display_base_url(base_url) if provider is LLMProvider.custom else None, + base_url=_display_base_url(base_url) if provider in {LLMProvider.custom, LLMProvider.llama_cpp} else None, structured_output=( record.structured_output - if provider is LLMProvider.custom and record and record.structured_output is not None + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and record and record.structured_output is not None else None ), ) @@ -100,17 +100,17 @@ def save_provider_config( ).first() env = config.LLM_PROVIDER_ENV[provider.value] - if provider is not LLMProvider.custom and body.structured_output is not None: - raise LLMNotAvailableError("Only the custom provider accepts a structured_output override.") - if provider is not LLMProvider.custom and body.base_url is not None: - raise LLMNotAvailableError("Only the custom provider accepts a configurable base URL.") + if provider not in {LLMProvider.custom, LLMProvider.llama_cpp} and body.structured_output is not None: + raise LLMNotAvailableError("Only OpenAI-compatible custom providers accept a structured_output override.") + if provider not in {LLMProvider.custom, LLMProvider.llama_cpp} and body.base_url is not None: + raise LLMNotAvailableError("Only OpenAI-compatible custom providers accept a configurable base URL.") - base_url = body.base_url if provider is LLMProvider.custom and body.base_url is not None else None - if provider is LLMProvider.custom: - base_url = base_url or (record.base_url if record else None) or env.get("BASE_URL") or None + base_url = body.base_url if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and body.base_url is not None else None + if provider in {LLMProvider.custom, LLMProvider.llama_cpp}: + base_url = base_url or (record.base_url if record else None) or env.get("BASE_URL") or SPECS[provider].default_base_url or None if not base_url: - raise LLMNotAvailableError("CUSTOM_BASE_URL is required.") - base_url = _validate_custom_url(base_url) + raise LLMNotAvailableError(f"{SPECS[provider].setting('BASE_URL')} is required.") + base_url = _validate_provider_url(base_url, provider) api_key_was_provided = "api_key" in body.model_fields_set and body.api_key is not None api_key = _provided_secret(body.api_key) if api_key_was_provided else None @@ -129,7 +129,7 @@ def save_provider_config( secret = _provided_secret(body.api_key) record.api_key_encrypted = encrypt_api_key(secret) if secret else None record.key_hint = secret[-4:] if secret else None - if provider is LLMProvider.custom: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp}: record.base_url = base_url if body.structured_output is not None: record.structured_output = body.structured_output @@ -186,11 +186,11 @@ def validate_provider_config( db: Session = Depends(get_db), ): validate_provider(provider) - if provider is not LLMProvider.custom and body.structured_output is not None: + if provider not in {LLMProvider.custom, LLMProvider.llama_cpp} and body.structured_output is not None: return LLMProviderValidationResult( provider=provider, ok=False, - detail="Only the custom provider accepts a structured_output override.", + detail="Only OpenAI-compatible custom providers accept a structured_output override.", error_code="LLM_CONFIG_ERROR", ) api_key = _provided_secret(body.api_key) @@ -202,16 +202,18 @@ def validate_provider_config( error_code="LLM_AUTH_FAILED", ) base_url = body.base_url - if provider is LLMProvider.custom: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp}: + if not base_url: + base_url = config.LLM_PROVIDER_ENV[provider.value].get("BASE_URL") or SPECS[provider].default_base_url or None if not base_url: return LLMProviderValidationResult( provider=provider, ok=False, - detail="CUSTOM_BASE_URL is required.", + detail=f"{SPECS[provider].setting('BASE_URL')} is required.", error_code="LLM_CONFIG_ERROR", ) try: - base_url = _validate_custom_url(base_url) + base_url = _validate_provider_url(base_url, provider) except LLMError as exc: return LLMProviderValidationResult(provider=provider, ok=False, detail=exc.message, error_code=exc.error_code) @@ -219,7 +221,7 @@ def validate_provider_config( return LLMProviderValidationResult( provider=provider, ok=False, - detail="Only the custom provider accepts a configurable base URL.", + detail="Only OpenAI-compatible custom providers accept a configurable base URL.", error_code="LLM_CONFIG_ERROR", ) @@ -242,7 +244,7 @@ def validate_provider_config( ) except LLMError as exc: detail = _redact(exc.message, api_key) - if provider is LLMProvider.custom and "has no model list" in detail: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and "has no model list" in detail: detail = "This server does not expose a model list. Validate a model ID manually." return LLMProviderValidationResult( provider=provider, ok=False, detail=detail, error_code=exc.error_code @@ -262,7 +264,7 @@ def validate_and_add_manual_model( ).first() env = config.LLM_PROVIDER_ENV[provider.value] base_url = ( - existing_config.base_url if provider is LLMProvider.custom and existing_config else None + existing_config.base_url if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and existing_config else None ) or env.get("BASE_URL") or SPECS[provider].default_base_url _require_external_consent(db, provider, base_url) candidate = registry_for_candidate( @@ -317,22 +319,23 @@ def _provided_secret(value: SecretStr | None) -> str: return value.get_secret_value().strip() if value else "" -def _validate_custom_url(value: str) -> str: +def _validate_provider_url(value: str, provider: LLMProvider) -> str: + setting = SPECS[provider].setting("BASE_URL") try: parsed = urlparse(value) except ValueError: - raise LLMNotAvailableError("CUSTOM_BASE_URL must be an HTTP or HTTPS URL with a hostname.") from None + raise LLMNotAvailableError(f"{setting} must be an HTTP or HTTPS URL with a hostname.") from None if parsed.username is not None or parsed.password is not None: - raise LLMNotAvailableError("CUSTOM_BASE_URL must not include embedded credentials.") + raise LLMNotAvailableError(f"{setting} must not include embedded credentials.") if parsed.query or parsed.fragment: - raise LLMNotAvailableError("CUSTOM_BASE_URL must not include query parameters or a fragment.") + raise LLMNotAvailableError(f"{setting} must not include query parameters or a fragment.") from app.services.llm.settings import _check_url from app.services.llm.errors import LLMConfigError try: - return _check_url("CUSTOM_BASE_URL", value) + return _check_url(setting, value) except LLMConfigError: - raise LLMNotAvailableError("CUSTOM_BASE_URL must be an HTTP or HTTPS URL with a hostname.") from None + raise LLMNotAvailableError(f"{setting} must be an HTTP or HTTPS URL with a hostname.") from None def _display_base_url(value: str) -> str: diff --git a/app/api/schemas/enums.py b/app/api/schemas/enums.py index 61033da..8975282 100644 --- a/app/api/schemas/enums.py +++ b/app/api/schemas/enums.py @@ -118,6 +118,7 @@ class OutputFormat(str, Enum): class LLMProvider(str, Enum): ollama = "ollama" + llama_cpp = "llama_cpp" openai = "openai" gemini = "gemini" anthropic = "anthropic" diff --git a/app/core/config.py b/app/core/config.py index bb85041..1750d3e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -70,6 +70,7 @@ def _provider_env(prefix: str, **defaults: str) -> dict[str, str]: "gemini": _provider_env("GEMINI"), "anthropic": _provider_env("ANTHROPIC"), "custom": _provider_env("CUSTOM"), + "llama_cpp": _provider_env("LLAMA_CPP"), } WHISPER_HOST = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/") diff --git a/app/services/llm/specs.py b/app/services/llm/specs.py index 4bcd326..85776cf 100644 --- a/app/services/llm/specs.py +++ b/app/services/llm/specs.py @@ -87,6 +87,10 @@ def setting(self, key: str) -> str: headers=(("anthropic-version", "2023-06-01"),), ), LLMProvider.custom: ProviderSpec(LLMProvider.custom, needs_key=False), + # llama.cpp serves the same OpenAI-compatible API with a local default. + LLMProvider.llama_cpp: ProviderSpec( + LLMProvider.llama_cpp, "http://localhost:8080/v1", needs_key=False + ), } DEFAULT_OLLAMA_INSTALLABLE_MODELS: tuple[str, ...] = ( diff --git a/app/services/llm/user_config.py b/app/services/llm/user_config.py index 1422117..f3d6f61 100644 --- a/app/services/llm/user_config.py +++ b/app/services/llm/user_config.py @@ -26,6 +26,7 @@ LLMProvider.gemini, LLMProvider.anthropic, LLMProvider.custom, + LLMProvider.llama_cpp, }) _cache_lock = threading.Lock() _credential_state_lock = threading.Lock() @@ -149,7 +150,7 @@ def validate_provider(provider: LLMProvider) -> None: if provider not in EDITABLE_PROVIDERS: from app.services.llm.errors import LLMNotAvailableError - raise LLMNotAvailableError("Only OpenAI, Gemini, Anthropic, and custom providers can be configured here.") + raise LLMNotAvailableError("Only OpenAI, Gemini, Anthropic, custom, and llama.cpp providers can be configured here.") def registry_for_candidate( @@ -259,11 +260,11 @@ def _build_registry( unavailable_credentials.add(provider.value) if api_key: provider_settings["API_KEY"] = api_key - if provider is LLMProvider.custom and record.base_url: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and record.base_url: provider_settings["BASE_URL"] = record.base_url - if provider is LLMProvider.custom and record.structured_output is not None: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and record.structured_output is not None: provider_settings["STRUCTURED_OUTPUT"] = str(record.structured_output).lower() - if not provider_settings.get("MODELS") and (api_key or (provider is LLMProvider.custom and record.base_url)): + if not provider_settings.get("MODELS") and (api_key or (provider in {LLMProvider.custom, LLMProvider.llama_cpp} and record.base_url)): provider_settings["MODELS"] = "*" if candidate: @@ -271,9 +272,9 @@ def _build_registry( provider_settings = provider_env[provider.value] if override["api_key"] is not None: provider_settings["API_KEY"] = override["api_key"] - if provider is LLMProvider.custom and override["base_url"] is not None: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and override["base_url"] is not None: provider_settings["BASE_URL"] = override["base_url"] - if provider is LLMProvider.custom and override["structured_output"] is not None: + if provider in {LLMProvider.custom, LLMProvider.llama_cpp} and override["structured_output"] is not None: provider_settings["STRUCTURED_OUTPUT"] = str(override["structured_output"]).lower() if not provider_settings.get("MODELS"): provider_settings["MODELS"] = "*" diff --git a/benchmark/.env.example b/benchmark/.env.example index a55aa35..5fb152e 100644 --- a/benchmark/.env.example +++ b/benchmark/.env.example @@ -6,6 +6,17 @@ OLLAMA_HOST=http://localhost:11434 OLLAMA_MODEL=qwen2.5:1.5b OLLAMA_TIMEOUT=300 +# --- Optional llama.cpp provider ------------------------------------------- +# The model ID must match llama-server's /v1/models response. +# Keep these commented to leave the default provider as Ollama. +# LLM_PROVIDER=llama_cpp +# LLM_MODEL=your-model-id +# LLAMA_CPP_BASE_URL=http://localhost:8080/v1 +# LLAMA_CPP_MODELS=your-model-id +# LLAMA_CPP_API_KEY= +# LLM_TIMEOUT=300 +# LLM_ALLOW_EXTERNAL=false + # --- LLM judge ------------------------------------------------------------ # JUDGE_HOST defaults to OLLAMA_HOST. # JUDGE_HOST=http://localhost:11434 diff --git a/benchmark/LLAMA_CPP_COMPARISON_REPORT_TEMPLATE.md b/benchmark/LLAMA_CPP_COMPARISON_REPORT_TEMPLATE.md new file mode 100644 index 0000000..f44e2da --- /dev/null +++ b/benchmark/LLAMA_CPP_COMPARISON_REPORT_TEMPLATE.md @@ -0,0 +1,91 @@ +# FireForm Issue #702 — Ollama vs. llama.cpp comparison + +> Fill this report only after the benchmark has completed. Keep raw run folders, `summary.csv`, `run_info.json`, and runtime profile CSVs as evidence. Do not infer missing measurements. + +## 1. Reproducibility record + +| Item | Value | +|---|---| +| Date/time and timezone | TODO | +| FireForm commit SHA | TODO | +| OS and kernel | TODO | +| CPU model / cores | TODO | +| RAM | TODO | +| GPU / VRAM (if used) | TODO | +| Python / dependency versions | From `run_info.json` | +| ICS documents included | TODO | +| Number of repetitions per provider | At least 3 | +| Judge model and host | TODO; keep identical across runs | +| Prompt SHA-256 | From `run_info.json` | + +## 2. Model equivalence and runtime configuration + +| Item | Ollama | llama.cpp | +|---|---|---| +| Model family and exact weights | TODO | TODO | +| Artifact SHA-256 / digest | TODO | GGUF SHA-256: TODO | +| Quantization | TODO | TODO | +| Context window | TODO | TODO | +| Server version/build SHA | TODO | TODO | +| Exact server launch command | TODO | TODO | +| API/provider config (omit secrets) | TODO | `llama_cpp` / OpenAI-compatible (or `custom` if running before the explicit provider lands) | +| Warm-up procedure | TODO | TODO | + +Confirm that the model weights are equivalent. If exact equivalence is not possible, state this as a limitation and do not attribute all observed differences to the runtime alone. + +## 3. Per-run results + +Include one row per repetition and provider. Use the same documents and scoring configuration in all runs. + +| Provider | Run | Documents scored | Skipped | Total wall time (s) | Median doc latency (s) | Judged accuracy (%) | Populated accuracy (%) | Raw accuracy (%) | Missing fields | Unsupported fields | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| Ollama | 1 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | +| Ollama | 2 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | +| Ollama | 3 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | +| llama.cpp | 1 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | +| llama.cpp | 2 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | +| llama.cpp | 3 | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO | + +## 4. Resource usage + +Use the `profile_runtime.py` CSV captured on the model-server host. Record GPU measurements separately if available. + +| Metric | Ollama | llama.cpp | Measurement method | +|---|---:|---:|---| +| Mean CPU usage (% of one core) | TODO | TODO | profile CSV | +| Peak CPU usage (% of one core) | TODO | TODO | profile CSV | +| Peak resident RAM (MiB) | TODO | TODO | profile CSV | +| Peak GPU VRAM (MiB) | TODO / N/A | TODO / N/A | TODO | + +## 5. Validity checks and limitations + +- Same FireForm commit and prompt: TODO +- Same document set and narrative order: TODO +- Same judge model/settings: TODO +- Same weights/quantization: TODO +- Any skipped documents or failed judge calls: TODO +- Cold-start versus warm-run behavior: TODO +- Remote server or unmeasured GPU resource limits: TODO +- Other confounding factors: TODO + +Runs with different skipped-document sets must not be compared as if they cover the same workload. Report failed or unavailable judge results instead of treating them as correct. + +## 6. Summary and recommendation + +**Latency:** TODO (report fill-phase median and variation across repeated runs, plus end-to-end document time; do not rely only on the fastest run). + +**Accuracy:** TODO (state any regression or uncertainty). + +**Resource use:** TODO. + +**Integration / maintenance costs:** TODO. + +**Recommendation:** Adopt llama.cpp / keep Ollama / gather more evidence. Explain the trade-off and the exact scope of the recommendation. Do not recommend a runtime solely from a single run or from latency if accuracy regresses. + +## 7. Artifacts + +- Ollama run folders: TODO +- llama.cpp run folders: TODO +- Runtime profile CSVs: TODO +- `run_info.json` files: TODO +- Commands used: TODO diff --git a/benchmark/README.md b/benchmark/README.md index 7d54593..d3b90be 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -2,13 +2,15 @@ Runs `app/services/form_filler` on the ICS narratives and scores the filled fields against hand-written ground truth, with an LLM judge for answers that -are right but worded differently. +are right but worded differently. The same runner can select any configured +FireForm LLM provider, including an OpenAI-compatible `custom` endpoint such +as a local llama.cpp server. ## Setup ```bash pip install -r requirements.txt -cp benchmark/.env.example benchmark/.env # then set OLLAMA_HOST etc. +cp benchmark/.env.example benchmark/.env # set the model and judge settings ``` `benchmark/.env` is gitignored, so a private server address stays local. @@ -19,23 +21,80 @@ Settings (see `config.py` and `.env.example`): | `OLLAMA_HOST` | `http://localhost:11434` | model under test (read by `app.core.config`) | | `OLLAMA_MODEL` | `qwen2.5:1.5b` | model under test | | `OLLAMA_TIMEOUT` | `300` | seconds per fill request | +| `LLM_PROVIDER` | `ollama` | default provider, when `--provider` is omitted | +| `LLM_MODEL` | provider default | default model when configured for the default provider | +| `CUSTOM_BASE_URL` | — | OpenAI-compatible server URL, e.g. `http://localhost:8080/v1` | +| `CUSTOM_API_KEY` | — | key expected by the custom server; use its documented local value | +| `CUSTOM_MODELS` | — | comma-separated model IDs allowed for the custom provider | +| `LLAMA_CPP_BASE_URL` | `http://localhost:8080/v1` | llama-server OpenAI-compatible endpoint | +| `LLAMA_CPP_API_KEY` | empty | optional key if configured by the server | +| `LLAMA_CPP_MODELS` | — | comma-separated llama.cpp model IDs allowed | +| `LLM_TIMEOUT` | `OLLAMA_TIMEOUT` | request timeout used by provider adapters | | `JUDGE_HOST` | `OLLAMA_HOST` | LLM judge | | `JUDGE_MODEL` | `qwen3.5:4b` | LLM judge | | `JUDGE_TIMEOUT_SECONDS` | `300` | seconds per judge request | -Shell environment variables override `.env`. Both models must be pulled on -their Ollama server (`ollama pull qwen2.5:1.5b`, `ollama pull qwen3.5:4b`). +Shell environment variables override `.env`. The judge still uses the existing +Ollama-native endpoint; keep `JUDGE_HOST` pointed at the same separate Ollama judge +server for both runs so scoring is consistent and judge load does not skew target-server +CPU/RAM measurements. The Ollama model must +be pulled on its server. For llama.cpp, start its OpenAI-compatible server first +and set `CUSTOM_BASE_URL`, `CUSTOM_MODELS`, and (if required) `CUSTOM_API_KEY`. ## Running ```bash cd benchmark -python3 run.py # all 56 narratives (~10 min on a GPU server) -python3 run.py -d ics_203_1 ics_206_1 # a subset -python3 run.py -m qwen3.5:4b # another model under test -python3 run.py --baseline # full run, then publish it (see Baselines) +python3 run.py # configured provider/model, all narratives +python3 run.py -d ics_203_1 ics_206_1 # same subset for each provider +python3 run.py --provider ollama -m qwen2.5:1.5b # Ollama run +python3 run.py --provider llama_cpp -m your-model-id # explicit llama.cpp provider +python3 run.py --provider llama_cpp -m your-model-id -d ics_203_1 ics_206_1 +python3 run.py --baseline --provider ollama # full run, then publish baseline ``` +Use exactly the same `-d` document list for each provider. For a fair runtime +comparison, pin the same model weights and quantization where possible, use the +same FireForm commit and prompt, keep the judge model/configuration fixed, and +run multiple repetitions. llama.cpp's `/v1/models` name must match the `-m` +argument and be allowed by `LLAMA_CPP_MODELS`. Do not compare results from runs +that skipped different documents without reporting those skips. + +### CPU and memory profiling (Linux, on the model server) + +`run.py` records both fill-phase wall time (schema construction, inference and PDF +writing) and total document-processing wall time in `summary.csv`. CPU/RAM belong to the +inference server process, not the Python benchmark process, so sample the server +on the machine where it runs. The helper writes a time-series CSV to the +gitignored `benchmark/results/` directory: + +```bash +python3 benchmark/profile_runtime.py --label ollama \ + --process-regex 'ollama' -- \ + python3 benchmark/run.py --provider ollama -m qwen2.5:1.5b -d ics_203_1 ics_206_1 + +python3 benchmark/profile_runtime.py --label llama-cpp \ + --process-regex 'llama-server' -- \ + python3 benchmark/run.py --provider llama_cpp -m your-model-id -d ics_203_1 ics_206_1 +``` + +Run the profiler on the same host as the server. It samples matching Linux +processes' combined RSS and CPU usage (100% means one fully used CPU core). +If the server is remote, collect its CPU/RAM on that machine separately. Record +the hardware, OS, server command/build, GGUF SHA-256, context size, quantization, +warm/cold state, and profile CSV location in the report. GPU VRAM is not exposed +by `/proc`; collect it separately with the platform's GPU monitoring tools. + +Summarize profiler samples and compare provider summaries with these helpers: + +```bash +python3 benchmark/summarize_runtime.py benchmark/results/runtime-profile_*.csv +python3 benchmark/compare_benchmark_runs.py path/to/ollama/summary.csv path/to/llama_cpp/summary.csv --output benchmark/results/comparison.csv +``` + +`run_info.json` also records scored and attempted documents per minute. The comparison +helper reports skipped documents and differing success sets. + A document whose model call fails or times out, or whose output is cut off mid-JSON (runaway generation hitting `num_predict`), is skipped and listed; it is left out of the averages. @@ -46,7 +105,7 @@ Each run gets its own folder, `results/_