Zum Inhalt

touchstone/embed.py

"""Embedding throughput (`touchstone embed`).

RAG hangs on embedding throughput, not just chat, so this is a separate run:
N chunks of ~chunk_tokens against /v1/embeddings, measuring embeddings/s, total
time and peak RAM. The host sampler runs decoupled, same as the chat runner.
"""

from __future__ import annotations

import threading
import time
from pathlib import Path
from typing import Protocol

from touchstone import prompts as prompts_mod
from touchstone.config import Config
from touchstone.merge import aggregate_window, load_samples_jsonl
from touchstone.models import EmbedResult
from touchstone.sampler import HostSampler, read_power_source


class EmbedClient(Protocol):
    engine: str
    engine_version: str

    def embed(self, *, model: str, inputs: list[str]) -> int: ...


def make_chunks(num_chunks: int, chunk_tokens: int, counter: prompts_mod.TokenCounter) -> list[str]:
    """Deterministic corpus: num_chunks texts each padded to ~chunk_tokens."""
    base = prompts_mod.pad_to_tokens(chunk_tokens, counter)
    # Vary each chunk slightly so a server can't trivially cache identical inputs.
    return [f"[Dok {i}] {base}" for i in range(num_chunks)]


def run_embed(
    config: Config,
    client: EmbedClient,
    *,
    run_dir: Path,
    batch_size: int = 16,
) -> EmbedResult:
    run_dir.mkdir(parents=True, exist_ok=True)
    resources_path = run_dir / "resources_embed.jsonl"

    counter = prompts_mod.get_counter(config.embed.model)
    chunks = make_chunks(config.embed.num_chunks, config.embed.chunk_tokens, counter)

    stop = threading.Event()
    sampler = HostSampler(config.server_process_match, powermetrics=config.power_check)
    sampler_thread = threading.Thread(
        target=sampler.run_to_file, args=(resources_path, stop), daemon=True
    )
    sampler_thread.start()

    power_source = read_power_source()
    t0 = time.perf_counter()
    n_done = 0
    try:
        for i in range(0, len(chunks), batch_size):
            batch = chunks[i : i + batch_size]
            n_done += client.embed(model=config.embed.model, inputs=batch)
    finally:
        total_s = time.perf_counter() - t0
        stop.set()
        sampler_thread.join(timeout=3)

    samples = load_samples_jsonl(resources_path)
    agg = aggregate_window(samples)
    eps = n_done / total_s if total_s > 0 else 0.0

    return EmbedResult(
        machine=config.machine,
        model=config.embed.model,
        engine=getattr(client, "engine", "openai-compat"),
        engine_version=getattr(client, "engine_version", "unknown"),
        num_chunks=n_done,
        chunk_tokens=config.embed.chunk_tokens,
        total_s=total_s,
        embeddings_per_s=eps,
        peak_rss_mb=agg.peak_rss_mb,
        sys_used_mb=agg.sys_used_mb,
        power_source=power_source,
        throttled=agg.throttled,
        seed=config.seed,
    )


def render_embed_md(result: EmbedResult, *, date_str: str) -> str:
    rss = f"{result.peak_rss_mb / 1024:.1f} GB" if result.peak_rss_mb else "—"
    return "\n".join(
        [
            "# touchstone — Embedding-Durchsatz",
            "",
            f"> **Datum:** {date_str} · **Maschine:** {result.machine} · "
            f"**Engine:** {result.engine} {result.engine_version} · "
            f"**Power:** {result.power_source}",
            "",
            "| Maschine | Modell | N Chunks | Chunk-Tok | Embeddings/s | Gesamtzeit (s) | Peak-RAM |",
            "|---|---|---|---|---|---|---|",
            f"| {result.machine} | {result.model} | {result.num_chunks} | "
            f"{result.chunk_tokens} | {result.embeddings_per_s:.1f} | "
            f"{result.total_s:.1f} | {rss} |",
            "",
        ]
    )