mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
828a6e55d3
* refactor(retain): format pgvector literals without orjson orjson was a single-purpose dependency: one function (`embedding_to_pgvector`), on the retain write path only, rendering an embedding as the '[0.1,...]' literal asyncpg binds to `vector`. It played no part in HTTP serialization — there is no ORJSONResponse in the API, and recall builds its query literal with plain str(). Measurement decided it. On `perf-test --suite retain --scale small` (200 items, 384d, mock LLM + pg0) the orjson calls totalled 2.9ms of a ~1.9s retain, and paired throughput runs could not tell the two implementations apart from noise (orjson 75-124 items/s, replacement 81-115). What replaces it is one C-level %-format call per vector against a per-dimension cached "[%.9g,...]" template: ~1.8x faster than the repr() generator that was already the non-finite fallback, ~5x slower than orjson. No pure-Python formatter closes that gap — orjson formats floats with SIMD Ryu in Rust. Beating it means not emitting text at all, which the benchmark now measures as `binary_pgvector` (~2 orders of magnitude faster, 2.8x fewer wire bytes) but which needs an asyncpg codec for the `vector` type and would still leave Oracle on the text path. Values are narrowed to float32 before formatting, exactly once. Applying %.9g straight to a float64 rounds twice — to nine digits, then to float32 — and the two roundings pick different neighbouring float32s for ~0.8% of values. Narrowing first makes the single remaining rounding the one PostgreSQL would have done, so stored bytes are unchanged. The new regression test samples 4,096 elements, where that rate misses by ~31. The literal is now fixed-width rather than shortest-form (0.100000001 where orjson wrote 0.1) — same float32, ~12% more SQL text. The orjson version floor stays in pyproject as a transitive-only pin (langsmith still pulls it), matching how pyjwt is handled. * build: drop orjson from the runtime dependency closure The previous commit removed the last import; this removes the package from what production resolves. orjson's only other path into the environment is langchain-text-splitters -> langchain-core -> langsmith -> orjson, and langchain has been test-only since #3756 — retain's chunker is its own streaming RecursiveCharacterTextSplitter, and langchain survives purely as the reference implementation test_chunking_matches_legacy.py diffs against (with a guard test asserting the reference is still really langchain). So the `orjson>=3.11.6` floor follows langsmith into the test extra and the dev group, exactly as the langchain-core and langsmith floors already did when langchain left the runtime. Deleting the floor outright would have dropped the unbounded-recursion DoS bound for the version langsmith pulls. uv export --no-dev -> orjson absent uv export -> orjson 3.11.7 (via langsmith, test only) uv.lock is unchanged. * test: vendor the chunking oracle and drop langchain, langsmith and orjson orjson's last path into the environment was the test extra: langchain-text-splitters -> langchain-core -> langsmith -> orjson. langchain was there for one reason — the differential chunking tests need the splitter retain used before #3756 as their oracle, and that oracle has to be genuinely the old implementation or the tests pass vacuously. So the oracle moves in-tree. tests/chunking_reference.py transcribes langchain 1.1.2's RecursiveCharacterTextSplitter (MIT), specialised to the one configuration retain ever used: chunk_overlap=0, keep_separator default "start", length_function=len, non-regex separators. The branches that configuration cannot reach are left out rather than transcribed untested. The transcription was verified against the real splitter before the dependency was removed: 20,079 comparisons — every case and size the chunking tests use, plus 20,000 seeded fuzz documents built from the fragment shapes that break splitters (empty strings, whitespace runs, CRLF, CJK, oversized tokens) — with zero mismatches. tests/test_chunking_reference_matches_langchain.py keeps that check runnable: uv run --with langchain-text-splitters pytest \ tests/test_chunking_reference_matches_langchain.py It skips by default, since langchain is deliberately absent. test_the_legacy_reference_really_is_the_old_implementation loses its live langchain call, so its anchor becomes a table of five expected splits — one per separator tier, including the per-character fallback — produced BY langchain and pasted in. A reference that quietly became the new implementation has to reproduce all five to slip through. uv export --no-dev -> no orjson, no langchain uv export -> no orjson, no langchain uv.lock loses 392 lines.
453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""Microbenchmark for pgvector float vector serialization on retain and import paths.
|
|
|
|
Retain and import paths convert embeddings to pgvector vector literals:
|
|
* ``memories.pg.writes.insert_facts_batch`` — serializes 50~500 facts per batch into pgvector literals;
|
|
* ``retain.link_utils.compute_semantic_links_within_batch`` / temp table generation — prepares records;
|
|
* ``memories.pg.writes.update_memory_unit_embedding`` — updates individual memory embeddings.
|
|
|
|
The baseline implementation used a Python generator:
|
|
"[" + ",".join(repr(float(value)) for value in embedding) + "]"
|
|
For 500 facts (768,000 floats), this allocated 768,000 PyFloat objects + 768,000 PyUnicode
|
|
strings, consuming ~200ms of pure CPU time and generating ~15MB of allocation churn.
|
|
|
|
The variants measured are:
|
|
``prod``
|
|
The production ``embedding_to_pgvector`` call: one C-level ``%``-format per vector
|
|
against a per-dimension cached ``"[%.9g,...]"`` template, after narrowing to float32.
|
|
``listcomp_str``
|
|
List comprehension with ``str()``: ``"[" + ",".join([str(v) for v in emb]) + "]"``
|
|
``baseline_generator``
|
|
The previous generator baseline: ``"[" + ",".join(repr(float(v)) for v in emb) + "]"``
|
|
``binary_pgvector``
|
|
Emits pgvector's binary wire format instead of a text literal.
|
|
|
|
This function used to serialize through orjson, which was the sole reason the API depended
|
|
on it — one function, on the retain write path, playing no part in HTTP serialization. The
|
|
dependency was dropped once measurement showed how little it bought end to end: on a real
|
|
``perf-test --suite retain`` run (200 items, 384d) the orjson calls totalled 2.9ms of a
|
|
~1.9s retain, and paired throughput runs could not distinguish the two implementations from
|
|
noise. What replaced it is ``prod`` above, roughly 1.8x faster than the generator baseline
|
|
and ~5x slower than orjson was — no pure-Python formatter closes that gap, because orjson
|
|
formats floats with SIMD Ryu in Rust.
|
|
|
|
``binary_pgvector`` is the unexploited win and the reason this file keeps measuring it:
|
|
skipping text entirely is ~2 orders of magnitude faster than the baseline and ~2.8x smaller
|
|
on the wire, but it needs an asyncpg codec registered for the ``vector`` type, and Oracle
|
|
would still need the text encoder (it stores the literal as a CLOB). Left measured, not
|
|
built, so the trade-off stays a number rather than a memory.
|
|
|
|
Usage (from the repo root):
|
|
./scripts/benchmarks/run-vector-serialization-bench.sh
|
|
./scripts/benchmarks/run-vector-serialization-bench.sh --repeats 10 --json out.json
|
|
./scripts/benchmarks/run-vector-serialization-bench.sh --workload batch_200_openai_1536
|
|
"""
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import struct
|
|
import time
|
|
import tracemalloc
|
|
from array import array
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
from hindsight_api.engine.retain.types import embedding_to_pgvector
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
console = Console()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Workload:
|
|
"""A batch of embedding vectors shaped like actual production call sites."""
|
|
|
|
name: str
|
|
call_site: str
|
|
batch_size: int
|
|
dim: int
|
|
data_type: str # "packed" (array('f')), "list" (list[float]), "numpy" (np.ndarray)
|
|
vectors: list[Any]
|
|
|
|
@property
|
|
def total_floats(self) -> int:
|
|
return self.batch_size * self.dim
|
|
|
|
|
|
@dataclass
|
|
class VariantResult:
|
|
"""One variant measured against one workload."""
|
|
|
|
workload: str
|
|
variant: str
|
|
wall_ms: float # best of --repeats, milliseconds
|
|
cpu_ms: float # process CPU (all threads) over that same best run
|
|
peak_kib: float # tracemalloc peak of a separate single run
|
|
total_floats: int
|
|
m_floats_per_sec: float
|
|
matches_baseline: bool
|
|
out_bytes: int # total serialized size — what actually travels to Postgres
|
|
|
|
|
|
# --- Variant implementations ---
|
|
|
|
|
|
def _v_prod(vectors: Sequence[Any]) -> list[str]:
|
|
return [embedding_to_pgvector(emb) for emb in vectors]
|
|
|
|
|
|
def _v_baseline_generator(vectors: Sequence[Any]) -> list[str]:
|
|
res = []
|
|
for emb in vectors:
|
|
if isinstance(emb, str):
|
|
res.append(emb)
|
|
else:
|
|
res.append("[" + ",".join(repr(float(value)) for value in emb) + "]")
|
|
return res
|
|
|
|
|
|
def _v_listcomp_str(vectors: Sequence[Any]) -> list[str]:
|
|
res = []
|
|
for emb in vectors:
|
|
if isinstance(emb, str):
|
|
res.append(emb)
|
|
else:
|
|
res.append("[" + ",".join([str(value) for value in emb]) + "]")
|
|
return res
|
|
|
|
|
|
def _v_binary_pgvector(vectors: Sequence[Any]) -> list[bytes]:
|
|
"""Skip text entirely: emit pgvector's binary wire format.
|
|
|
|
Layout is ``int16 dim | int16 unused | dim * big-endian float32``. Reaching this
|
|
from the engine needs an asyncpg codec registered for the ``vector`` type; it is
|
|
measured here to size the prize before paying for that plumbing.
|
|
"""
|
|
res = []
|
|
for emb in vectors:
|
|
if isinstance(emb, str):
|
|
arr = np.array(emb[1:-1].split(","), dtype=np.float32) if len(emb) > 2 else np.empty(0, np.float32)
|
|
elif isinstance(emb, array):
|
|
arr = np.frombuffer(emb, dtype=np.float32)
|
|
else:
|
|
arr = np.asarray(emb, dtype=np.float32)
|
|
res.append(struct.pack(">HH", arr.shape[0], 0) + arr.astype(">f4").tobytes())
|
|
return res
|
|
|
|
|
|
def _decode_pgvector_binary(buf: bytes) -> str:
|
|
"""Render a binary-format vector back as a literal, so conformance can compare it."""
|
|
dim = struct.unpack_from(">HH", buf, 0)[0]
|
|
values = np.frombuffer(buf, dtype=">f4", count=dim, offset=4).astype(np.float32)
|
|
return "[" + ",".join(repr(float(v)) for v in values) + "]"
|
|
|
|
|
|
def build_workloads(seed: int) -> list[Workload]:
|
|
rng = random.Random(seed)
|
|
|
|
def make_vector(dim: int) -> list[float]:
|
|
return [rng.uniform(-1.0, 1.0) for _ in range(dim)]
|
|
|
|
def make_packed_batch(count: int, dim: int) -> list[array]:
|
|
return [array("f", make_vector(dim)) for _ in range(count)]
|
|
|
|
def make_list_batch(count: int, dim: int) -> list[list[float]]:
|
|
return [make_vector(dim) for _ in range(count)]
|
|
|
|
return [
|
|
Workload(
|
|
name="single_bge_384",
|
|
call_site="single MiniLM/BGE fact update (384d)",
|
|
batch_size=1,
|
|
dim=384,
|
|
data_type="packed",
|
|
vectors=make_packed_batch(1, 384),
|
|
),
|
|
Workload(
|
|
name="single_openai_1536",
|
|
call_site="single OpenAI fact insert (1536d)",
|
|
batch_size=1,
|
|
dim=1536,
|
|
data_type="packed",
|
|
vectors=make_packed_batch(1, 1536),
|
|
),
|
|
Workload(
|
|
name="batch_20_gemini_768",
|
|
call_site="Gemini-001 conversational retain sub-batch (768d, 20 facts)",
|
|
batch_size=20,
|
|
dim=768,
|
|
data_type="packed",
|
|
vectors=make_packed_batch(20, 768),
|
|
),
|
|
Workload(
|
|
name="batch_200_openai_1536",
|
|
call_site="typical OpenAI retain batch (1536d, 200 facts)",
|
|
batch_size=200,
|
|
dim=1536,
|
|
data_type="packed",
|
|
vectors=make_packed_batch(200, 1536),
|
|
),
|
|
Workload(
|
|
name="batch_500_large_doc_1536",
|
|
call_site="large document retain batch (1536d, 500 facts)",
|
|
batch_size=500,
|
|
dim=1536,
|
|
data_type="packed",
|
|
vectors=make_packed_batch(500, 1536),
|
|
),
|
|
Workload(
|
|
name="batch_200_raw_list_1536",
|
|
call_site="raw API list[float] before packing (1536d, 200 facts)",
|
|
batch_size=200,
|
|
dim=1536,
|
|
data_type="list",
|
|
vectors=make_list_batch(200, 1536),
|
|
),
|
|
]
|
|
|
|
|
|
def build_variants() -> dict[str, Callable[[Sequence[Any]], list[str]]]:
|
|
return {
|
|
"baseline_generator": _v_baseline_generator,
|
|
"listcomp_str": _v_listcomp_str,
|
|
"prod": _v_prod,
|
|
"binary_pgvector": _v_binary_pgvector,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Timing:
|
|
wall_ms: float
|
|
cpu_ms: float
|
|
results: list[str] | list[bytes]
|
|
|
|
|
|
def _measure(fn: Callable[[Sequence[Any]], list[str]], vectors: Sequence[Any], repeats: int) -> Timing:
|
|
fn(vectors[:1]) # warmup
|
|
best_wall = float("inf")
|
|
best_cpu = 0.0
|
|
res = []
|
|
for _ in range(repeats):
|
|
gc.collect()
|
|
t0, c0 = time.perf_counter(), time.process_time()
|
|
res = fn(vectors)
|
|
wall = time.perf_counter() - t0
|
|
cpu = time.process_time() - c0
|
|
if wall < best_wall:
|
|
best_wall, best_cpu = wall, cpu
|
|
return Timing(wall_ms=best_wall * 1000, cpu_ms=best_cpu * 1000, results=res)
|
|
|
|
|
|
def _measure_peak_kib(fn: Callable[[Sequence[Any]], list[str]], vectors: Sequence[Any]) -> float:
|
|
gc.collect()
|
|
tracemalloc.start()
|
|
try:
|
|
fn(vectors)
|
|
_, peak = tracemalloc.get_traced_memory()
|
|
finally:
|
|
tracemalloc.stop()
|
|
return peak / 1024
|
|
|
|
|
|
def _check_conformance_output(baseline_str: str, candidate_str: str | bytes) -> bool:
|
|
if isinstance(candidate_str, bytes):
|
|
# Binary variants carry the same floats in pgvector wire format; decode and
|
|
# compare numerically so they are held to the same conformance bar as text.
|
|
candidate_str = _decode_pgvector_binary(candidate_str)
|
|
if not (candidate_str.startswith("[") and candidate_str.endswith("]")):
|
|
return False
|
|
b_parts = baseline_str[1:-1].split(",") if len(baseline_str) > 2 else []
|
|
c_parts = candidate_str[1:-1].split(",") if len(candidate_str) > 2 else []
|
|
if len(b_parts) != len(c_parts):
|
|
return False
|
|
for b_item, c_item in zip(b_parts, c_parts):
|
|
b_val = float(b_item)
|
|
c_val = float(c_item)
|
|
if math.isnan(b_val):
|
|
if not math.isnan(c_val):
|
|
return False
|
|
elif math.isinf(b_val):
|
|
if not (math.isinf(c_val) and (b_val > 0) == (c_val > 0)):
|
|
return False
|
|
else:
|
|
if abs(b_val - c_val) > 1e-6 and not math.isclose(b_val, c_val, rel_tol=1e-5, abs_tol=1e-6):
|
|
return False
|
|
return True
|
|
|
|
|
|
_CONFORMANCE_CASES: dict[str, Any] = {
|
|
"standard_floats": array("f", [0.1, -0.25, 3.5, 0.0, -1.0, 1e-7, 123.456]),
|
|
"subnormal_small": array("f", [1e-15, -1e-20, 1e-35]),
|
|
"large_floats": array("f", [1e5, -2.5e6, 3.4e38]),
|
|
"negative_zero": array("f", [-0.0, 0.0]),
|
|
"non_finite": array("f", [float("nan"), float("inf"), -float("inf"), 1.0]),
|
|
"empty_vector": array("f", []),
|
|
"already_string": "[0.123,-0.456,7.89]",
|
|
"raw_float_list": [0.1, -0.25, 3.5, 0.0, -1.0, 1e-7, 123.456],
|
|
"raw_float_tuple": (0.1, -0.25, 3.5, 0.0, -1.0, 1e-7, 123.456),
|
|
"numpy_array_f32": np.array([0.1, -0.25, 3.5, 0.0, -1.0, 1e-7, 123.456], dtype=np.float32),
|
|
}
|
|
|
|
|
|
def _conformance() -> None:
|
|
variants = build_variants()
|
|
table = Table(title="[bold]conformance[/bold] — vector serialization vs baseline", title_justify="left")
|
|
table.add_column("test case")
|
|
for name in variants:
|
|
table.add_column(name, justify="right")
|
|
|
|
for case, inp in _CONFORMANCE_CASES.items():
|
|
cells: list[str] = []
|
|
baseline_res = _v_baseline_generator([inp])[0]
|
|
for name, fn in variants.items():
|
|
try:
|
|
got = fn([inp])[0]
|
|
except Exception as err:
|
|
cells.append(f"[red]{type(err).__name__}[/red]")
|
|
continue
|
|
is_valid = _check_conformance_output(baseline_res, got)
|
|
if is_valid:
|
|
cells.append("[green]ok[/green]")
|
|
else:
|
|
cells.append("[red]MISMATCH[/red]")
|
|
table.add_row(case, *cells)
|
|
|
|
console.print(table)
|
|
console.print()
|
|
|
|
|
|
def run(workloads: Sequence[Workload], repeats: int) -> list[VariantResult]:
|
|
variants = build_variants()
|
|
results: list[VariantResult] = []
|
|
for wl in workloads:
|
|
baseline_res: list[str] | None = None
|
|
for name, fn in variants.items():
|
|
try:
|
|
timing = _measure(fn, wl.vectors, repeats)
|
|
peak_kib = _measure_peak_kib(fn, wl.vectors)
|
|
except Exception as err:
|
|
console.print(f"[red]{wl.name}/{name}: {type(err).__name__}: {err}[/red]")
|
|
continue
|
|
|
|
if baseline_res is None:
|
|
baseline_res = timing.results
|
|
|
|
matches = True
|
|
if len(timing.results) != len(baseline_res):
|
|
matches = False
|
|
else:
|
|
for b_str, c_str in zip(baseline_res, timing.results):
|
|
if not _check_conformance_output(b_str, c_str):
|
|
matches = False
|
|
break
|
|
|
|
m_floats_s = (wl.total_floats / timing.wall_ms / 1000) if timing.wall_ms > 0 else 0.0
|
|
out_bytes = sum(len(item) for item in timing.results)
|
|
results.append(
|
|
VariantResult(
|
|
workload=wl.name,
|
|
variant=name,
|
|
wall_ms=timing.wall_ms,
|
|
cpu_ms=timing.cpu_ms,
|
|
peak_kib=peak_kib,
|
|
total_floats=wl.total_floats,
|
|
m_floats_per_sec=m_floats_s,
|
|
matches_baseline=matches,
|
|
out_bytes=out_bytes,
|
|
)
|
|
)
|
|
return results
|
|
|
|
|
|
def _render(workloads: Sequence[Workload], results: Sequence[VariantResult]) -> None:
|
|
by_workload: dict[str, list[VariantResult]] = {}
|
|
for r in results:
|
|
by_workload.setdefault(r.workload, []).append(r)
|
|
|
|
for wl in workloads:
|
|
rows = by_workload.get(wl.name, [])
|
|
if not rows:
|
|
continue
|
|
baseline = rows[0]
|
|
table = Table(
|
|
title=(
|
|
f"[bold]{wl.name}[/bold] — {wl.call_site} "
|
|
f"({wl.batch_size} fact(s), {wl.dim}d, {wl.total_floats:,} floats, {wl.data_type})"
|
|
),
|
|
title_justify="left",
|
|
)
|
|
table.add_column("variant")
|
|
table.add_column("wall ms", justify="right")
|
|
table.add_column("speedup", justify="right")
|
|
table.add_column("cpu ms", justify="right")
|
|
table.add_column("cpu/wall", justify="right")
|
|
table.add_column("peak KiB", justify="right")
|
|
table.add_column("wire KiB", justify="right")
|
|
table.add_column("Mfloat/s", justify="right")
|
|
table.add_column("conformance", justify="right")
|
|
|
|
for r in rows:
|
|
speedup = baseline.wall_ms / r.wall_ms if r.wall_ms > 0 else float("inf")
|
|
table.add_row(
|
|
r.variant,
|
|
f"{r.wall_ms:.3f}",
|
|
"—" if r.variant == baseline.variant else f"{speedup:.2f}x",
|
|
f"{r.cpu_ms:.3f}",
|
|
f"{(r.cpu_ms / r.wall_ms):.2f}" if r.wall_ms > 0 else "—",
|
|
f"{r.peak_kib:,.0f}",
|
|
f"{r.out_bytes / 1024:,.0f}",
|
|
f"{r.m_floats_per_sec:.2f}",
|
|
"[green]ok[/green]" if r.matches_baseline else "[red]MISMATCH[/red]",
|
|
)
|
|
console.print(table)
|
|
console.print()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--repeats", type=int, default=5, help="timed repeats per variant (best-of); default 5")
|
|
parser.add_argument("--seed", type=int, default=1234, help="seed for vector generation; default 1234")
|
|
parser.add_argument(
|
|
"--workload",
|
|
action="append",
|
|
help="run only this workload (repeatable); default all",
|
|
)
|
|
parser.add_argument("--json", dest="json_path", help="also write raw results to this path")
|
|
parser.add_argument(
|
|
"--no-conformance",
|
|
action="store_true",
|
|
help="skip the conformance check on adversarial inputs",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
workloads = build_workloads(args.seed)
|
|
if args.workload:
|
|
wanted = set(args.workload)
|
|
unknown = wanted - {w.name for w in workloads}
|
|
if unknown:
|
|
parser.error(f"unknown workload(s): {', '.join(sorted(unknown))}")
|
|
workloads = [w for w in workloads if w.name in wanted]
|
|
|
|
console.print(
|
|
f"[dim]Running pgvector serialization microbenchmarks | cpu_count={os.cpu_count()} | repeats={args.repeats}[/dim]\n"
|
|
)
|
|
if not args.no_conformance:
|
|
_conformance()
|
|
results = run(workloads, repeats=args.repeats)
|
|
_render(workloads, results)
|
|
|
|
if args.json_path:
|
|
with open(args.json_path, "w") as f:
|
|
json.dump([asdict(r) for r in results], f, indent=2)
|
|
console.print(f"[dim]wrote {args.json_path}[/dim]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|