Compare commits

...
Author SHA1 Message Date
John KennedyandClaude Opus 4.6 b24d88f54a refactor: extract _track helper for available_channels sync in apply_writes
Replace 5 repeated inline blocks that sync available_channels with
a local _track() helper that checks is_available() and updates the
set, returning the availability bool for callers that also need to
update updated_channels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:03:48 +00:00
John KennedyandClaude Opus 4.6 49e78713cc chore: remove benchmark profiling mode from PR
Move --profile flag and Makefile targets (benchmark-profile,
benchmark-profile-spy) to a separate patch for a future PR.
This PR now contains only runtime performance optimizations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:33 +00:00
John KennedyandClaude Opus 4.6 49beffcc49 perf: remove unnecessary typing.cast calls in add_messages
The cast(BaseMessageChunk, m) calls in add_messages were no-ops at
runtime but accounted for ~92K function calls per react_agent_100x
benchmark run (~3ms overhead). Remove them since message_chunk_to_message
already handles the type internally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:30 +00:00
John KennedyandClaude Opus 4.6 45d111876c perf: remove isinstance from hash funcs, flatten task_path_str
Two micro-optimizations:

1. Remove per-element isinstance check in _xxhash_str/_uuid5_str — all
   call sites pass string parts, so encode() directly without checking.

2. Flatten task_path_str to avoid recursive calls for the common case
   of tuple elements being str or int (not nested tuples).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:27 +00:00
John KennedyandClaude Opus 4.6 8f71184573 perf: track available channels set to eliminate O(n) scan in apply_writes
Maintain a set of currently-available channel names, updated incrementally
as channels change state, so the step-bump loop in apply_writes only
iterates available channels instead of scanning all channels with
is_available(). For sequential_1000 this reduces function calls by ~54%
and improves overall runtime by ~26%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:16 +00:00
John KennedyandClaude Opus 4.6 d08be136b2 perf: add benchmark profiling mode and fix quadratic repr bottleneck
Add --profile flag to bench/__main__.py that bypasses pyperf and runs
each benchmark under cProfile, printing per-benchmark hotspot summaries
and writing .prof files for later analysis. Add benchmark-profile and
benchmark-profile-spy Makefile targets.

Fix O(n^2) performance regression in _get_model_input_state where
f-strings eagerly evaluated repr(state) on every call, triggering
pydantic __repr__ across all accumulated messages. Move error message
construction into the error path so repr is only called when needed.
This yields a 3-5x speedup on react_agent_100x benchmarks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:01:43 +00:00
4 changed files with 76 additions and 37 deletions
+2 -8
View File
@@ -191,14 +191,8 @@ def add_messages(
if not isinstance(right, list):
right = [right] # type: ignore[assignment]
# coerce to message
left = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
right = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(right)
]
left = [message_chunk_to_message(m) for m in convert_to_messages(left)]
right = [message_chunk_to_message(m) for m in convert_to_messages(right)]
# assign missing ids
for m in left:
if m.id is None:
+55 -24
View File
@@ -220,6 +220,7 @@ def apply_writes(
tasks: Iterable[WritesProtocol],
get_next_version: GetNextVersion | None,
trigger_to_nodes: Mapping[str, Sequence[str]],
available_channels: set[str] | None = None,
) -> set[str]:
"""Apply writes from a set of tasks (usually the tasks from a Pregel step)
to the checkpoint and channels, and return managed values writes to be applied
@@ -266,6 +267,18 @@ def apply_writes(
None,
)
# Sync available_channels with channel's actual availability state.
# Returns True if the channel is available (for callers that also need
# to update updated_channels).
def _track(chan: str) -> bool:
avail = channels[chan].is_available()
if available_channels is not None:
if avail:
available_channels.add(chan)
else:
available_channels.discard(chan)
return avail
# Consume all channels that were read
for chan in {
chan
@@ -275,6 +288,7 @@ def apply_writes(
}:
if channels[chan].consume() and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
_track(chan)
# Group writes by channel
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
@@ -296,18 +310,28 @@ def apply_writes(
if channels[chan].update(vals) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if channels[chan].is_available():
if _track(chan):
updated_channels.add(chan)
else:
_track(chan)
# Channels that weren't updated in this step are notified of a new step
if bump_step:
for chan in channels:
if channels[chan].is_available() and chan not in updated_channels:
if channels[chan].update(EMPTY_SEQ) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if channels[chan].is_available():
updated_channels.add(chan)
candidates = (
available_channels - updated_channels
if available_channels is not None
else (
chan
for chan in channels
if channels[chan].is_available() and chan not in updated_channels
)
)
for chan in candidates:
if channels[chan].update(EMPTY_SEQ) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if _track(chan):
updated_channels.add(chan)
# If this is (tentatively) the last superstep, notify all channels of finish
if bump_step and updated_channels.isdisjoint(trigger_to_nodes):
@@ -315,8 +339,10 @@ def apply_writes(
if channels[chan].finish() and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if channels[chan].is_available():
if _track(chan):
updated_channels.add(chan)
else:
_track(chan)
# Return managed values writes to be applied externally
return updated_channels
@@ -494,7 +520,7 @@ PUSH_TRIGGER = (PUSH,)
class _TaskIDFn(Protocol):
def __call__(self, namespace: bytes, *parts: str | bytes) -> str:
def __call__(self, namespace: bytes, *parts: str) -> str:
pass
@@ -1165,32 +1191,37 @@ def _proc_input(
return val
def _uuid5_str(namespace: bytes, *parts: str | bytes) -> str:
def _uuid5_str(namespace: bytes, *parts: str) -> str:
"""Generate a UUID from the SHA-1 hash of a namespace and str parts."""
sha = sha1(namespace, usedforsecurity=False)
sha.update(b"".join(p.encode() if isinstance(p, str) else p for p in parts))
sha.update(b"".join(p.encode() for p in parts))
hex = sha.hexdigest()
return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
def _xxhash_str(namespace: bytes, *parts: str | bytes) -> str:
def _xxhash_str(namespace: bytes, *parts: str) -> str:
"""Generate a UUID from the XXH3 hash of a namespace and str parts."""
hex = xxh3_128_hexdigest(
namespace + b"".join(p.encode() if isinstance(p, str) else p for p in parts)
)
hex = xxh3_128_hexdigest(namespace + b"".join(p.encode() for p in parts))
return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
def task_path_str(tup: str | int | tuple) -> str:
def task_path_str(tup: str | int | tuple | list) -> str:
"""Generate a string representation of the task path."""
return (
f"~{', '.join(task_path_str(x) for x in tup)}"
if isinstance(tup, (tuple, list))
else f"{tup:010d}"
if isinstance(tup, int)
else str(tup)
)
if isinstance(tup, (tuple, list)):
parts: list[str] = []
for x in tup:
if isinstance(x, int):
parts.append(f"{x:010d}")
elif isinstance(x, (tuple, list)):
parts.append(task_path_str(x))
else:
parts.append(str(x))
return f"~{', '.join(parts)}"
elif isinstance(tup, int):
return f"{tup:010d}"
else:
return str(tup)
LAZY_ATOMIC_COUNTER_LOCK = threading.Lock()
+11
View File
@@ -180,6 +180,7 @@ class PregelLoop:
_migrate_checkpoint: Callable[[Checkpoint], None] | None
submit: Submit
channels: Mapping[str, BaseChannel]
_available_channels: set[str]
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
@@ -545,6 +546,7 @@ class PregelLoop:
self.tasks.values(),
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
# produce values output
if not self.updated_channels.isdisjoint(
@@ -675,6 +677,7 @@ class PregelLoop:
[PregelTaskWrites((), INPUT, null_writes, [])],
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
if updated_channels is not None:
updated_channels.update(null_updated_channels)
@@ -717,6 +720,7 @@ class PregelLoop:
],
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
# save input checkpoint
self.updated_channels = updated_channels
@@ -844,6 +848,7 @@ class PregelLoop:
self.tasks.values(),
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
if not updated_channels.isdisjoint(
(self.output_keys,)
@@ -1114,6 +1119,9 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
)
self._available_channels: set[str] = {
k for k, v in self.channels.items() if v.is_available()
}
self.stack.push(self._suppress_interrupt)
self.status = "input"
self.step = self.checkpoint_metadata["step"] + 1
@@ -1295,6 +1303,9 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
)
self._available_channels: set[str] = {
k for k, v in self.channels.items() if v.is_available()
}
self.stack.push(self._suppress_interrupt)
self.status = "input"
self.step = self.checkpoint_metadata["step"] + 1
@@ -638,15 +638,18 @@ def create_react_agent(
messages = (
_get_state_value(state, "llm_input_messages")
) or _get_state_value(state, "messages")
error_msg = f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}"
else:
messages = _get_state_value(state, "messages")
error_msg = (
f"Expected input to call_model to have 'messages' key, but got {state}"
)
if messages is None:
raise ValueError(error_msg)
if pre_model_hook is not None:
raise ValueError(
f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}"
)
else:
raise ValueError(
f"Expected input to call_model to have 'messages' key, but got {state}"
)
_validate_chat_history(messages)
# we're passing messages under `messages` key, as this is expected by the prompt