fleet-memory/hindsight-dev/benchmarks/perf/retain_perf.py
Nicolò Boschi aefb3fcf4d
fix: improve async batch retain with large payloads (#366)
* fix: improve async batch retain with large payloads

* fix: improve async batch retain with large payloads

* api

* api

* api

* api

* api

* Clean up perf benchmark: keep only Python files

- Remove README.md and PERFORMANCE_FINDINGS.md
- Remove results/ JSON files (gitignored)
- Remove test_data/ directory
- Keep only __init__.py and retain_perf.py

* docs: explain automatic batch optimization for async retain

- Add section explaining Hindsight automatically handles batch sizing
- Users don't need to manually tune batch sizes with async mode
- Hindsight splits large batches (>10k tokens) into optimized sub-batches
- Include example showing best practices

* docs: remove emojis and code example from performance page

* fix: correct OperationDetails type to match API response

- Change optional fields to use | null instead of ?
- Fixes TypeScript compilation error in control plane build

* fix: use discriminated union for OperationDetails type

- Support both success and error states properly
- Fixes TypeScript error when setting error state

* fix: use unique document_ids in batch retain examples

- Each item in a batch must have unique document_id
- Update both Python and JavaScript examples
- Fixes test-doc-examples CI failure

* chore: trigger CI

* fix: test mocking and duplicate document_ids in examples

- Mock _get_pool() in test_async_retain_tags.py to avoid _initialized error
- Set _initialized = True on mocked MemoryEngine instances
- Fix duplicate document_ids in retain.py and retain.mjs examples

* fix: properly mock async pool/connection and fix more duplicate document_ids

- Use AsyncMock for pool.acquire() to fix 'can't be used in await' error
- Fix duplicate document_ids in retain-async examples (retain.py and retain.mjs)
- Remove batch-level document_id parameter that caused duplicates

* ci: collect all doc example failures and show summary

- Run all Python/Node.js/CLI examples regardless of individual failures
- Collect failure list and display summary at the end
- Show pass/fail count and list of failed files
- Exit with failure only after running all examples

* refactor: extract doc example testing to standalone script

- Create scripts/test-doc-examples.sh to run all examples
- Collects logs of failed examples separately
- Shows full error logs only for failures at the end
- Clean summary with pass/fail counts
- Proper exit codes
- Replaces inline bash in CI workflow

* fix: doc examples - duplicate document_ids and error handling

- retain.py: move document_id to item level to avoid duplicates
- documents.mjs: add error handling for getDocument to show clear error message

* fix: update tests for duplicate document_id validation

- test_async_retain_tags: verify operation structure instead of exact UUID
- test_delete_bank: use unique document_ids (team-doc-1, team-doc-2)
2026-02-16 12:51:42 +01:00

456 lines
15 KiB
Python

"""
Retain operation performance benchmark.
Measures retain operation performance by:
1. Loading a document from a file or directory
2. Sending it to the retain endpoint via HTTP (batched for directories)
3. Measuring time taken and token usage
4. Reporting performance metrics
Usage:
# Single file
uv run python hindsight-dev/benchmarks/perf/retain_perf.py --document <file_path> [options]
# Directory (batches all files)
uv run python hindsight-dev/benchmarks/perf/retain_perf.py --document <dir_path> [options]
"""
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
import httpx
from rich.console import Console
from rich.table import Table
console = Console()
async def retain_via_memory_engine(
bank_id: str,
items: list[dict[str, Any]],
) -> tuple[float, dict[str, Any]]:
"""
Send retain request directly to MemoryEngine (in-memory, no HTTP).
Args:
bank_id: Bank ID to retain into
items: List of items to retain
Returns:
Tuple of (duration_seconds, response_data)
"""
from hindsight_api import MemoryEngine
from hindsight_api.models import RequestContext
# Initialize memory engine
memory = MemoryEngine(
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0"),
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-20b"),
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
)
await memory.initialize()
# Measure time
start_time = time.time()
try:
# Call retain_batch_async directly
result, usage = await memory.retain_batch_async(
bank_id=bank_id,
contents=items,
request_context=RequestContext(),
return_usage=True,
)
duration = time.time() - start_time
# Format response to match HTTP response structure
response_data = {
"success": True,
"bank_id": bank_id,
"items_count": len(items),
"async": False,
"usage": usage.model_dump() if usage else None,
}
return duration, response_data
finally:
# Close memory engine connections
pool = await memory._get_pool()
await pool.close()
async def retain_via_http(
base_url: str,
bank_id: str,
items: list[dict[str, Any]],
timeout: float = 300.0,
) -> tuple[float, dict[str, Any]]:
"""
Send retain request via HTTP and measure performance.
Args:
base_url: API base URL (e.g., http://localhost:8000)
bank_id: Bank ID to retain into
items: List of items to retain (each with 'content' and optional 'context', 'metadata')
timeout: Request timeout in seconds
Returns:
Tuple of (duration_seconds, response_data)
"""
url = f"{base_url}/v1/default/banks/{bank_id}/memories"
payload = {"items": items}
headers = {"Content-Type": "application/json"}
# Measure time
start_time = time.time()
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
result = response.json()
duration = time.time() - start_time
return duration, result
def load_documents(path: str) -> tuple[list[dict[str, Any]], int]:
"""
Load document(s) from file or directory.
For directories: loads all .json, .txt, and .md files
For JSON files with 'content' field: extracts content
For other files: reads entire file as content
Returns:
Tuple of (items_list, total_content_length)
items_list: List of dicts with 'content' and optional 'metadata'/'context'
total_content_length: Total character count across all documents
"""
file_path = Path(path)
if not file_path.exists():
raise FileNotFoundError(f"Path not found: {path}")
items = []
total_length = 0
if file_path.is_file():
# Single file
content, metadata = _load_single_file(file_path)
total_length = len(content)
item = {"content": content}
if metadata:
item["metadata"] = metadata
items.append(item)
else:
# Directory - load all supported files
supported_extensions = {".json", ".txt", ".md"}
files = [f for f in file_path.rglob("*") if f.is_file() and f.suffix in supported_extensions]
if not files:
raise ValueError(f"No supported files (.json, .txt, .md) found in directory: {path}")
console.print(f"Found {len(files)} files in directory")
for file in sorted(files):
try:
content, metadata = _load_single_file(file)
total_length += len(content)
item = {"content": content}
if metadata:
item["metadata"] = metadata
# Add filename as context for batch processing
item["context"] = f"Source: {file.name}"
items.append(item)
except Exception as e:
console.print(f"[yellow]Warning: Failed to load {file.name}: {e}[/yellow]")
continue
return items, total_length
def _load_single_file(file_path: Path) -> tuple[str, dict[str, Any] | None]:
"""
Load a single file and extract content.
Returns:
Tuple of (content, metadata)
"""
if file_path.suffix == ".json":
# Try to parse as JSON and extract 'content' field
try:
data = json.loads(file_path.read_text())
if isinstance(data, dict) and "content" in data:
# Extract metadata if present
metadata = data.get("metadata", {})
# Add doc_id to metadata if present
if "doc_id" in data:
metadata["doc_id"] = data["doc_id"]
return data["content"], metadata if metadata else None
else:
# Fallback: use entire JSON as string
return file_path.read_text(), None
except json.JSONDecodeError:
# Not valid JSON, read as text
return file_path.read_text(), None
else:
# Read as plain text
return file_path.read_text(), None
def display_results(
duration: float,
usage: dict[str, int] | None,
content_length: int,
bank_id: str,
num_documents: int,
) -> None:
"""Display benchmark results in a formatted table."""
table = Table(title="Retain Performance Benchmark Results")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table.add_row("Bank ID", bank_id)
table.add_row("Documents", f"{num_documents:,}")
table.add_row("Total Content Length", f"{content_length:,} chars")
if num_documents > 1:
table.add_row("Avg Content/Doc", f"{content_length / num_documents:,.0f} chars")
table.add_row("", "") # Separator
table.add_row("Duration", f"{duration:.3f}s")
table.add_row("Throughput", f"{content_length / duration:,.0f} chars/sec")
if num_documents > 1:
table.add_row("Docs/Second", f"{num_documents / duration:.2f}")
if usage:
table.add_row("", "") # Separator
table.add_row("Input Tokens", f"{usage.get('input_tokens', 0):,}")
table.add_row("Output Tokens", f"{usage.get('output_tokens', 0):,}")
table.add_row("Total Tokens", f"{usage.get('total_tokens', 0):,}")
table.add_row("Tokens/Second", f"{usage.get('total_tokens', 0) / duration:,.1f}")
if num_documents > 1:
table.add_row("Avg Tokens/Doc", f"{usage.get('total_tokens', 0) / num_documents:,.0f}")
else:
table.add_row("", "") # Separator
table.add_row("Token Usage", "Not available (async mode or error)")
console.print("\n")
console.print(table)
def save_results(
output_path: Path,
duration: float,
usage: dict[str, int] | None,
content_length: int,
bank_id: str,
document_path: str,
num_documents: int,
) -> None:
"""Save results to JSON file."""
results = {
"bank_id": bank_id,
"document_path": document_path,
"num_documents": num_documents,
"content_length": content_length,
"avg_content_per_doc": content_length / num_documents if num_documents > 0 else 0,
"duration_seconds": duration,
"chars_per_second": content_length / duration,
"docs_per_second": num_documents / duration if num_documents > 0 else 0,
"usage": usage,
}
if usage:
results["tokens_per_second"] = usage.get("total_tokens", 0) / duration
results["avg_tokens_per_doc"] = usage.get("total_tokens", 0) / num_documents if num_documents > 0 else 0
with open(output_path, "w") as f:
json.dump(results, f, indent=2)
console.print(f"\n[green]✓[/green] Results saved to {output_path}")
async def main():
"""Run the retain performance benchmark."""
parser = argparse.ArgumentParser(
description="Benchmark retain operation performance",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Benchmark with a single document file
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
--document ./test_data/large_doc.txt \\
--bank-id perf-test-001
# Benchmark with a directory (batches all files)
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
--document ~/Documents/my-docs/ \\
--bank-id perf-test-batch \\
--output results/batch_perf.json
# With custom API URL and save results
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
--document ./test_data/ \\
--bank-id perf-test-001 \\
--api-url http://localhost:8000 \\
--output results/retain_perf_001.json
""",
)
parser.add_argument(
"--document",
required=True,
help="Path to document file or directory (for directories, batches all .json/.txt/.md files)",
)
parser.add_argument(
"--bank-id",
default="perf-test",
help="Bank ID to use (default: perf-test)",
)
parser.add_argument(
"--context",
help="Optional context for the retain operation (only used for single file mode)",
)
parser.add_argument(
"--api-url",
default="http://localhost:8000",
help="API base URL (default: http://localhost:8000)",
)
parser.add_argument(
"--timeout",
type=float,
default=300.0,
help="Request timeout in seconds (default: 300)",
)
parser.add_argument(
"--output",
type=Path,
help="Path to save results JSON (optional)",
)
parser.add_argument(
"--in-memory",
action="store_true",
help="Use in-memory MemoryEngine instead of HTTP (bypasses API server, useful for isolating performance)",
)
args = parser.parse_args()
console.print("\n[bold cyan]Retain Performance Benchmark[/bold cyan]")
console.print("=" * 80)
# Check mode
if args.in_memory:
console.print("\n[cyan]Mode: IN-MEMORY (direct MemoryEngine, no HTTP)[/cyan]")
else:
console.print(f"\n[cyan]Mode: HTTP (via {args.api_url})[/cyan]")
# Check if server is running (skip for in-memory mode)
if not args.in_memory:
console.print(f"\n[1] Checking API server at {args.api_url}...")
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{args.api_url}/health", timeout=5.0)
response.raise_for_status()
console.print(" [green]✓[/green] API server is running")
except Exception as e:
console.print(f" [red]✗[/red] API server is not accessible: {e}")
console.print("\n[yellow]Please ensure the API server is running:[/yellow]")
console.print(" ./scripts/dev/start-api.sh")
sys.exit(1)
# Load document(s)
doc_path = Path(args.document)
if doc_path.is_dir():
console.print(f"\n[2] Loading documents from directory {args.document}...")
else:
console.print(f"\n[2] Loading document from {args.document}...")
try:
items, total_content_length = load_documents(args.document)
num_docs = len(items)
# Add context to single file if provided
if num_docs == 1 and args.context:
items[0]["context"] = args.context
console.print(
f" [green]✓[/green] Loaded {num_docs:,} document{'s' if num_docs > 1 else ''} ({total_content_length:,} characters)"
)
if num_docs > 1:
console.print(
f" [cyan]Average content per document: {total_content_length / num_docs:,.0f} chars[/cyan]"
)
except Exception as e:
console.print(f" [red]✗[/red] Failed to load documents: {e}")
sys.exit(1)
# Run benchmark
console.print(f"\n[3] {'Processing' if args.in_memory else 'Sending retain request to'} bank '{args.bank_id}'...")
console.print(f" [cyan]Retaining {num_docs:,} document{'s' if num_docs > 1 else ''} in batch...[/cyan]")
try:
if args.in_memory:
# In-memory mode: call MemoryEngine directly
duration, result = await retain_via_memory_engine(
bank_id=args.bank_id,
items=items,
)
else:
# HTTP mode: call API endpoint
duration, result = await retain_via_http(
base_url=args.api_url,
bank_id=args.bank_id,
items=items,
timeout=args.timeout,
)
console.print(f" [green]✓[/green] Retain completed in {duration:.3f}s")
# Extract usage
usage = result.get("usage")
except httpx.HTTPStatusError as e:
console.print(f" [red]✗[/red] HTTP error: {e.response.status_code}")
console.print(f" Response: {e.response.text}")
sys.exit(1)
except Exception as e:
console.print(f" [red]✗[/red] Request failed: {e}")
sys.exit(1)
# Display results
console.print("\n[4] Results:")
display_results(
duration=duration,
usage=usage,
content_length=total_content_length,
bank_id=args.bank_id,
num_documents=num_docs,
)
# Save results if requested
if args.output:
console.print("\n[5] Saving results...")
args.output.parent.mkdir(parents=True, exist_ok=True)
save_results(
output_path=args.output,
duration=duration,
usage=usage,
content_length=total_content_length,
bank_id=args.bank_id,
document_path=args.document,
num_documents=num_docs,
)
console.print("\n[bold green]✓ Benchmark Complete![/bold green]\n")
if __name__ == "__main__":
asyncio.run(main())