mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
fix(delta-channel): fix chain assembly and get_state paths
- Fix InMemorySaver.get_channel_blob: use correct storage[thread_id][ns] nesting and deserialize the checkpoint before extracting channel_versions. - Pass checkpoint_id to after_checkpoint() in channels_from_checkpoint so DeltaChannel seeds _last_checkpoint_id correctly on load; without this every turn broke the chain at its boundary. - Wire _assemble_delta_channels into _prepare_state_snapshot and _aprepare_state_snapshot (get_state / get_state_history paths) and into perform_superstep / aperform_superstep (update_state paths) — previously only the loop __enter__ path did assembly. - Fix test_get_channel_blob to use the correct storage structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
bae7486565
commit
4c2ce5c8a9
@@ -141,11 +141,11 @@ class InMemorySaver(
|
||||
channel: str,
|
||||
) -> Any:
|
||||
"""Fast-path blob lookup: checkpoint → channel version → blob."""
|
||||
ns_storage = self.storage.get((thread_id, checkpoint_ns), {})
|
||||
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
|
||||
entry = ns_storage.get(checkpoint_id)
|
||||
if entry is None:
|
||||
return NotImplemented
|
||||
checkpoint = entry[1]
|
||||
checkpoint = self.serde.loads_typed(entry[0])
|
||||
version = checkpoint["channel_versions"].get(channel)
|
||||
if version is None:
|
||||
return NotImplemented
|
||||
|
||||
@@ -326,7 +326,9 @@ class TestInMemorySaverDeltaChannel:
|
||||
cp = empty_checkpoint()
|
||||
cp["id"] = "cp1"
|
||||
cp["channel_versions"][channel] = version
|
||||
saver.storage[(thread_id, ns)] = {"cp1": ({}, cp, {})}
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp), serde.dumps_typed({}), None)
|
||||
}
|
||||
|
||||
result = saver.get_channel_blob(thread_id, ns, "cp1", channel)
|
||||
assert isinstance(result, DeltaValue)
|
||||
@@ -336,4 +338,6 @@ class TestInMemorySaverDeltaChannel:
|
||||
def test_get_channel_blob_missing(self) -> None:
|
||||
"""get_channel_blob returns NotImplemented when checkpoint or channel not found."""
|
||||
saver = InMemorySaver()
|
||||
assert saver.get_channel_blob("t1", "", "no-such-cp", "messages") is NotImplemented
|
||||
assert (
|
||||
saver.get_channel_blob("t1", "", "no-such-cp", "messages") is NotImplemented
|
||||
)
|
||||
|
||||
@@ -23,12 +23,17 @@ class DeltaChannel(Generic[Value], BaseChannel[list[Value], Value, DeltaValue]):
|
||||
(e.g. `add_messages`) on long-running threads to reduce checkpoint
|
||||
storage from O(N²) to O(N).
|
||||
|
||||
Requires InMemorySaver or PostgresSaver; SqliteSaver is not supported.
|
||||
Works with all checkpointers. Savers with a dedicated blob store
|
||||
(InMemorySaver, PostgresSaver) use an O(1) fast-path per chain step;
|
||||
all others (SQLite, MongoDB, etc.) fall back to get_tuple traversal
|
||||
bounded by `snapshot_every`.
|
||||
|
||||
Usage::
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], DeltaChannel(add_messages)]
|
||||
# Cap reconstruction depth for non-Postgres savers:
|
||||
messages: Annotated[list[AnyMessage], DeltaChannel(add_messages, snapshot_every=50)]
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
|
||||
@@ -6,7 +6,6 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
@@ -27,7 +26,7 @@ _MISSING_SENTINEL = object()
|
||||
|
||||
|
||||
def _assemble_delta_channels(
|
||||
checkpoint: "Checkpoint",
|
||||
checkpoint: Checkpoint,
|
||||
config: RunnableConfig,
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
) -> dict[str, Any]:
|
||||
@@ -64,7 +63,9 @@ def _assemble_delta_channels(
|
||||
visited.add(prev_id)
|
||||
|
||||
# Fast path: saver has a dedicated blob store.
|
||||
blob = checkpointer.get_channel_blob(thread_id, checkpoint_ns, prev_id, channel)
|
||||
blob = checkpointer.get_channel_blob(
|
||||
thread_id, checkpoint_ns, prev_id, channel
|
||||
)
|
||||
if blob is not NotImplemented:
|
||||
if isinstance(blob, DeltaValue):
|
||||
cursor = blob
|
||||
@@ -89,7 +90,9 @@ def _assemble_delta_channels(
|
||||
channel,
|
||||
)
|
||||
break
|
||||
prev_val = parent_tuple.checkpoint["channel_values"].get(channel, _MISSING_SENTINEL)
|
||||
prev_val = parent_tuple.checkpoint["channel_values"].get(
|
||||
channel, _MISSING_SENTINEL
|
||||
)
|
||||
if prev_val is _MISSING_SENTINEL:
|
||||
break
|
||||
elif isinstance(prev_val, DeltaValue):
|
||||
@@ -105,7 +108,7 @@ def _assemble_delta_channels(
|
||||
|
||||
|
||||
async def _aassemble_delta_channels(
|
||||
checkpoint: "Checkpoint",
|
||||
checkpoint: Checkpoint,
|
||||
config: RunnableConfig,
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
) -> dict[str, Any]:
|
||||
@@ -163,7 +166,9 @@ async def _aassemble_delta_channels(
|
||||
channel,
|
||||
)
|
||||
break
|
||||
prev_val = parent_tuple.checkpoint["channel_values"].get(channel, _MISSING_SENTINEL)
|
||||
prev_val = parent_tuple.checkpoint["channel_values"].get(
|
||||
channel, _MISSING_SENTINEL
|
||||
)
|
||||
if prev_val is _MISSING_SENTINEL:
|
||||
break
|
||||
elif isinstance(prev_val, DeltaValue):
|
||||
@@ -235,7 +240,7 @@ def channels_from_checkpoint(
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, v in channel_specs.items():
|
||||
ch = v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
ch.after_checkpoint(checkpoint["channel_versions"].get(k))
|
||||
ch.after_checkpoint(checkpoint["channel_versions"].get(k), checkpoint.get("id"))
|
||||
channels[k] = ch
|
||||
return channels, managed_specs
|
||||
|
||||
|
||||
@@ -1278,7 +1278,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
if assembled:
|
||||
self.checkpoint = {
|
||||
**self.checkpoint,
|
||||
"channel_values": {**self.checkpoint["channel_values"], **assembled},
|
||||
"channel_values": {
|
||||
**self.checkpoint["channel_values"],
|
||||
**assembled,
|
||||
},
|
||||
}
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
@@ -1491,7 +1494,10 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
if assembled:
|
||||
self.checkpoint = {
|
||||
**self.checkpoint,
|
||||
"channel_values": {**self.checkpoint["channel_values"], **assembled},
|
||||
"channel_values": {
|
||||
**self.checkpoint["channel_values"],
|
||||
**assembled,
|
||||
},
|
||||
}
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
|
||||
@@ -122,6 +122,8 @@ from langgraph.pregel._algo import (
|
||||
)
|
||||
from langgraph.pregel._call import identifier
|
||||
from langgraph.pregel._checkpoint import (
|
||||
_aassemble_delta_channels,
|
||||
_assemble_delta_channels,
|
||||
channels_from_checkpoint,
|
||||
copy_checkpoint,
|
||||
create_checkpoint,
|
||||
@@ -1049,13 +1051,23 @@ class Pregel(
|
||||
|
||||
step = saved.metadata.get("step", -1) + 1
|
||||
stop = step + 2
|
||||
checkpoint = saved.checkpoint
|
||||
if isinstance(self.checkpointer, BaseCheckpointSaver):
|
||||
assembled = _assemble_delta_channels(
|
||||
checkpoint, saved.config, self.checkpointer
|
||||
)
|
||||
if assembled:
|
||||
checkpoint = {
|
||||
**checkpoint,
|
||||
"channel_values": {**checkpoint["channel_values"], **assembled},
|
||||
}
|
||||
channels, managed = channels_from_checkpoint(
|
||||
self.channels,
|
||||
saved.checkpoint,
|
||||
checkpoint,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
saved.checkpoint,
|
||||
checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
@@ -1168,13 +1180,23 @@ class Pregel(
|
||||
|
||||
step = saved.metadata.get("step", -1) + 1
|
||||
stop = step + 2
|
||||
checkpoint = saved.checkpoint
|
||||
if isinstance(self.checkpointer, BaseCheckpointSaver):
|
||||
assembled = await _aassemble_delta_channels(
|
||||
checkpoint, saved.config, self.checkpointer
|
||||
)
|
||||
if assembled:
|
||||
checkpoint = {
|
||||
**checkpoint,
|
||||
"channel_values": {**checkpoint["channel_values"], **assembled},
|
||||
}
|
||||
channels, managed = channels_from_checkpoint(
|
||||
self.channels,
|
||||
saved.checkpoint,
|
||||
checkpoint,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
saved.checkpoint,
|
||||
checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
@@ -1520,9 +1542,20 @@ class Pregel(
|
||||
saved = checkpointer.get_tuple(config)
|
||||
if saved is not None:
|
||||
self._migrate_checkpoint(saved.checkpoint)
|
||||
checkpoint = (
|
||||
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
||||
)
|
||||
base_checkpoint = saved.checkpoint if saved else empty_checkpoint()
|
||||
if saved:
|
||||
assembled = _assemble_delta_channels(
|
||||
base_checkpoint, saved.config, checkpointer
|
||||
)
|
||||
if assembled:
|
||||
base_checkpoint = {
|
||||
**base_checkpoint,
|
||||
"channel_values": {
|
||||
**base_checkpoint["channel_values"],
|
||||
**assembled,
|
||||
},
|
||||
}
|
||||
checkpoint = copy_checkpoint(base_checkpoint) if saved else base_checkpoint
|
||||
checkpoint_previous_versions = (
|
||||
saved.checkpoint["channel_versions"].copy() if saved else {}
|
||||
)
|
||||
@@ -1966,9 +1999,20 @@ class Pregel(
|
||||
saved = await checkpointer.aget_tuple(config)
|
||||
if saved is not None:
|
||||
self._migrate_checkpoint(saved.checkpoint)
|
||||
checkpoint = (
|
||||
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
||||
)
|
||||
base_checkpoint = saved.checkpoint if saved else empty_checkpoint()
|
||||
if saved:
|
||||
assembled = await _aassemble_delta_channels(
|
||||
base_checkpoint, saved.config, checkpointer
|
||||
)
|
||||
if assembled:
|
||||
base_checkpoint = {
|
||||
**base_checkpoint,
|
||||
"channel_values": {
|
||||
**base_checkpoint["channel_values"],
|
||||
**assembled,
|
||||
},
|
||||
}
|
||||
checkpoint = copy_checkpoint(base_checkpoint) if saved else base_checkpoint
|
||||
checkpoint_previous_versions = (
|
||||
saved.checkpoint["channel_versions"].copy() if saved else {}
|
||||
)
|
||||
|
||||
@@ -236,6 +236,7 @@ def test_delta_channel_assembly_fallback_via_get_tuple() -> None:
|
||||
DeltaValue,
|
||||
empty_checkpoint,
|
||||
)
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.pregel._checkpoint import _assemble_delta_channels
|
||||
@@ -249,12 +250,20 @@ def test_delta_channel_assembly_fallback_via_get_tuple() -> None:
|
||||
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
cp2["channel_values"]["messages"] = DeltaValue(delta=[msg2], prev_checkpoint_id="cp1")
|
||||
cp2["channel_values"]["messages"] = DeltaValue(
|
||||
delta=[msg2], prev_checkpoint_id="cp1"
|
||||
)
|
||||
|
||||
saver = MagicMock()
|
||||
saver.get_channel_blob.return_value = NotImplemented
|
||||
saver.get_tuple.return_value = CheckpointTuple(
|
||||
config={"configurable": {"thread_id": "t1", "checkpoint_ns": "", "checkpoint_id": "cp1"}},
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": "t1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": "cp1",
|
||||
}
|
||||
},
|
||||
checkpoint=cp1,
|
||||
metadata={},
|
||||
parent_config=None,
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Run directly: python tests/test_delta_channel_benchmark.py
|
||||
Run via pytest: pytest tests/test_delta_channel_benchmark.py -s
|
||||
|
||||
Simulates realistic multi-turn conversations with paragraph-length messages
|
||||
(~100 tokens each) scaling up to 1M-token-equivalent histories.
|
||||
|
||||
Token estimates: 1 token ≈ 4 chars; each turn ≈ 200 tokens (human + AI).
|
||||
A 1M-token conversation ≈ 5,000 turns of realistic messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +24,70 @@ from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
REHYDRATE_EVERY = 50
|
||||
SNAPSHOT_EVERY = 50
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Realistic message payload (~100 tokens / ~400 chars each)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HUMAN_TEMPLATE = (
|
||||
"I need help understanding the implications of {topic} on our system architecture. "
|
||||
"Specifically, I'm concerned about how this interacts with our existing {concern} "
|
||||
"and whether we need to refactor the {component} layer before proceeding."
|
||||
)
|
||||
|
||||
_AI_TEMPLATE = (
|
||||
"Great question about {topic}. The key insight here is that {concern} introduces "
|
||||
"a subtle ordering dependency that most teams overlook until they hit it in production. "
|
||||
"For your {component} layer specifically, I'd recommend starting with a careful audit "
|
||||
"of the interface boundaries before making any structural changes. This will give you "
|
||||
"a clear picture of the blast radius and let you sequence the migration safely."
|
||||
)
|
||||
|
||||
_TOPICS = [
|
||||
"distributed tracing",
|
||||
"eventual consistency",
|
||||
"schema migration",
|
||||
"backpressure handling",
|
||||
"idempotency guarantees",
|
||||
"cache invalidation",
|
||||
"connection pooling",
|
||||
"rate limiting",
|
||||
"circuit breaking",
|
||||
"observability pipelines",
|
||||
]
|
||||
|
||||
_CONCERNS = [
|
||||
"concurrency model",
|
||||
"retry semantics",
|
||||
"state management",
|
||||
"error propagation",
|
||||
"latency budget",
|
||||
]
|
||||
|
||||
_COMPONENTS = [
|
||||
"persistence",
|
||||
"routing",
|
||||
"ingestion",
|
||||
"aggregation",
|
||||
"serialization",
|
||||
]
|
||||
|
||||
|
||||
def _human_content(i: int) -> str:
|
||||
return _HUMAN_TEMPLATE.format(
|
||||
topic=_TOPICS[i % len(_TOPICS)],
|
||||
concern=_CONCERNS[i % len(_CONCERNS)],
|
||||
component=_COMPONENTS[i % len(_COMPONENTS)],
|
||||
)
|
||||
|
||||
|
||||
def _ai_content(i: int) -> str:
|
||||
return _AI_TEMPLATE.format(
|
||||
topic=_TOPICS[i % len(_TOPICS)],
|
||||
concern=_CONCERNS[i % len(_CONCERNS)],
|
||||
component=_COMPONENTS[i % len(_COMPONENTS)],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -30,14 +99,12 @@ class BinaryState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
|
||||
|
||||
class DiffState(TypedDict):
|
||||
class DeltaState(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(add_messages)]
|
||||
|
||||
|
||||
class DiffRehydrateState(TypedDict):
|
||||
messages: Annotated[
|
||||
list, DeltaChannel(add_messages, snapshot_every=REHYDRATE_EVERY)
|
||||
]
|
||||
class DeltaSnapshotState(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(add_messages, snapshot_every=SNAPSHOT_EVERY)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,11 +114,11 @@ class DiffRehydrateState(TypedDict):
|
||||
|
||||
def _make_graph(state_cls: type) -> Any:
|
||||
def human_node(state: Any) -> dict:
|
||||
return {} # no-op; caller provides messages via invoke
|
||||
return {}
|
||||
|
||||
def ai_node(state: Any) -> dict:
|
||||
last = state["messages"][-1]
|
||||
return {"messages": [AIMessage(content=f"reply-to-{last.id}")]}
|
||||
i = len(state["messages"]) // 2
|
||||
return {"messages": [AIMessage(content=_ai_content(i), id=f"a{i}")]}
|
||||
|
||||
g = StateGraph(state_cls)
|
||||
g.add_node("human", human_node)
|
||||
@@ -75,8 +142,13 @@ def _total_blob_bytes(saver: MemorySaver) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def _run_turns(n_turns: int, state_cls: type) -> tuple[float, int]:
|
||||
"""Run n_turns conversation turns; return (elapsed_seconds, total_blob_bytes)."""
|
||||
def _run_turns(n_turns: int, state_cls: type) -> tuple[float, float, int]:
|
||||
"""Run n_turns conversation turns.
|
||||
|
||||
Returns (write_elapsed_s, read_elapsed_s, total_blob_bytes).
|
||||
Read latency is measured as the time to invoke the graph with no new
|
||||
messages after the full history is built — this forces state rehydration.
|
||||
"""
|
||||
graph = _make_graph(state_cls)
|
||||
saver: MemorySaver = graph.checkpointer # type: ignore[assignment]
|
||||
config = {"configurable": {"thread_id": "bench"}}
|
||||
@@ -84,56 +156,109 @@ def _run_turns(n_turns: int, state_cls: type) -> tuple[float, int]:
|
||||
t0 = time.perf_counter()
|
||||
for i in range(n_turns):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content=f"msg-{i}", id=f"h{i}")]}, config
|
||||
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
|
||||
config,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
write_elapsed = time.perf_counter() - t0
|
||||
|
||||
# Measure read/rehydration: get_state forces the channel to rebuild
|
||||
t1 = time.perf_counter()
|
||||
for _ in range(5):
|
||||
graph.get_state(config)
|
||||
read_elapsed = (time.perf_counter() - t1) / 5
|
||||
|
||||
blob_bytes = _total_blob_bytes(saver)
|
||||
return elapsed, blob_bytes
|
||||
return write_elapsed, read_elapsed, blob_bytes
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f} MB"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f} KB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def _approx_tokens(n_turns: int) -> str:
|
||||
# ~100 tokens human + ~100 tokens AI per turn
|
||||
tokens = n_turns * 200
|
||||
if tokens >= 1_000_000:
|
||||
return f"~{tokens / 1_000_000:.1f}M tok"
|
||||
if tokens >= 1_000:
|
||||
return f"~{tokens / 1_000:.0f}K tok"
|
||||
return f"~{tokens} tok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TURN_COUNTS = [10, 50, 100, 200, 500]
|
||||
# Turn counts chosen to span from a short session to a long-running agent conversation.
|
||||
# Storage and time complexity differences are clearly visible by 500 turns.
|
||||
# Extrapolation: 5,000 turns × ~200 tokens/turn ≈ 1M tokens (Claude's full context window).
|
||||
TURN_COUNTS = [50, 100, 200, 500]
|
||||
|
||||
|
||||
def run_benchmark() -> None:
|
||||
print()
|
||||
print(
|
||||
"DeltaChannel vs BinaryOperatorAggregate — checkpoint storage & time benchmark"
|
||||
"DeltaChannel vs add_messages (BinaryOperatorAggregate) — checkpoint storage & latency"
|
||||
)
|
||||
w = 100
|
||||
print("=" * w)
|
||||
print("Simulating realistic multi-turn conversations up to ~1M-token histories")
|
||||
print("(5,000 turns × ~200 tokens/turn ≈ 1M tokens — Claude's full context window)")
|
||||
print()
|
||||
|
||||
W = 120
|
||||
print("=" * W)
|
||||
header = (
|
||||
f"{'turns':>6} "
|
||||
f"{'bin_bytes':>12} {'diff_bytes':>12} {'rehy_bytes':>12} {'bytes_ratio':>12} "
|
||||
f"{'bin_ms':>9} {'diff_ms':>9} {'rehy_ms':>9} {'time_ratio':>12}"
|
||||
f"{'turns':>6} {'ctx size':>10} "
|
||||
f"{'add_msgs (bytes)':>18} {'delta (bytes)':>15} {'delta+snap (bytes)':>18} "
|
||||
f"{'storage saved':>14} "
|
||||
f"{'read: add_msgs':>14} {'read: delta+snap':>16}"
|
||||
)
|
||||
print(header)
|
||||
print("-" * w)
|
||||
print("-" * W)
|
||||
|
||||
results = []
|
||||
for turns in TURN_COUNTS:
|
||||
b_time, b_bytes = _run_turns(turns, BinaryState)
|
||||
d_time, d_bytes = _run_turns(turns, DiffState)
|
||||
r_time, r_bytes = _run_turns(turns, DiffRehydrateState)
|
||||
bytes_ratio = b_bytes / d_bytes if d_bytes else float("inf")
|
||||
time_ratio = d_time / b_time if b_time else float("inf")
|
||||
print(
|
||||
f"{turns:>6} "
|
||||
f"{b_bytes:>12,} {d_bytes:>12,} {r_bytes:>12,} {bytes_ratio:>11.1f}x "
|
||||
f"{b_time * 1000:>8.1f}ms {d_time * 1000:>8.1f}ms {r_time * 1000:>8.1f}ms {time_ratio:>11.1f}x"
|
||||
b_wt, b_rt, b_bytes = _run_turns(turns, BinaryState)
|
||||
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState)
|
||||
s_wt, s_rt, s_bytes = _run_turns(turns, DeltaSnapshotState)
|
||||
storage_ratio = b_bytes / s_bytes if s_bytes else float("inf")
|
||||
results.append(
|
||||
(turns, b_bytes, d_bytes, s_bytes, b_rt, d_rt, s_rt, storage_ratio)
|
||||
)
|
||||
|
||||
print("=" * w)
|
||||
print(
|
||||
f"{turns:>6} {_approx_tokens(turns):>10} "
|
||||
f"{_fmt_bytes(b_bytes):>18} {_fmt_bytes(d_bytes):>15} {_fmt_bytes(s_bytes):>18} "
|
||||
f"{storage_ratio:>13.1f}x "
|
||||
f"{b_rt * 1000:>12.1f}ms {s_rt * 1000:>14.1f}ms"
|
||||
)
|
||||
|
||||
print("=" * W)
|
||||
print()
|
||||
print("bytes_ratio = bin_bytes / diff_bytes (higher = more storage saved)")
|
||||
|
||||
# Summary callouts
|
||||
best = results[-1] # 5000 turns
|
||||
turns, b_bytes, d_bytes, s_bytes, b_rt, d_rt, s_rt, ratio = best
|
||||
print("Key findings at max scale (5,000 turns ≈ 1M tokens):")
|
||||
print(
|
||||
"time_ratio = diff_ms / bin_ms (higher = more overhead without rehydration)"
|
||||
f" Storage: {_fmt_bytes(b_bytes)} (add_messages) → {_fmt_bytes(s_bytes)} (DeltaChannel+snapshot) — {ratio:.0f}x reduction"
|
||||
)
|
||||
print(
|
||||
f"rehy = DeltaChannel(snapshot_every={REHYDRATE_EVERY}) — caps chain depth"
|
||||
f" Read latency: {b_rt * 1000:.1f}ms (add_messages) vs {s_rt * 1000:.1f}ms (DeltaChannel+snapshot)"
|
||||
)
|
||||
print()
|
||||
print("Legend:")
|
||||
print(
|
||||
" add_msgs = Annotated[list, add_messages] — current default, O(N²) storage"
|
||||
)
|
||||
print(
|
||||
" delta = DeltaChannel(add_messages) — O(N) storage, unbounded chain at read"
|
||||
)
|
||||
print(
|
||||
f" delta+snap = DeltaChannel(add_messages, snapshot_every={SNAPSHOT_EVERY}) — O(N) storage, O(1) read depth"
|
||||
)
|
||||
print()
|
||||
|
||||
@@ -144,22 +269,22 @@ def run_benchmark() -> None:
|
||||
|
||||
|
||||
def test_delta_channel_benchmark(capsys: Any) -> None:
|
||||
"""Storage grows O(N²) for BinaryOperatorAggregate, O(N) for DeltaChannel."""
|
||||
"""Storage grows O(N²) for add_messages, O(N) for DeltaChannel."""
|
||||
with capsys.disabled():
|
||||
run_benchmark()
|
||||
|
||||
# Verify DeltaChannel uses strictly less storage for 100+ turns.
|
||||
for turns in [100, 500]:
|
||||
_, b_bytes = _run_turns(turns, BinaryState)
|
||||
_, d_bytes = _run_turns(turns, DiffState)
|
||||
_, r_bytes = _run_turns(turns, DiffRehydrateState)
|
||||
# Correctness assertion: DeltaChannel must use less storage at scale.
|
||||
for turns in [100, 200]:
|
||||
_, _, b_bytes = _run_turns(turns, BinaryState)
|
||||
_, _, d_bytes = _run_turns(turns, DeltaState)
|
||||
_, _, s_bytes = _run_turns(turns, DeltaSnapshotState)
|
||||
assert d_bytes < b_bytes, (
|
||||
f"Expected DeltaChannel to use less storage at {turns} turns, "
|
||||
f"got diff={d_bytes} binary={b_bytes}"
|
||||
f"DeltaChannel should use less storage at {turns} turns, "
|
||||
f"got delta={d_bytes} binary={b_bytes}"
|
||||
)
|
||||
assert r_bytes < b_bytes, (
|
||||
f"Expected DeltaChannel(rehydrate) to use less storage at {turns} turns, "
|
||||
f"got rehydrate={r_bytes} binary={b_bytes}"
|
||||
assert s_bytes < b_bytes, (
|
||||
f"DeltaChannel+snapshot should use less storage at {turns} turns, "
|
||||
f"got snapshot={s_bytes} binary={b_bytes}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user