Skip to content

perf: eliminate full message-history deep clone in add_messages reducer - #30

Open
MuFengMuXue wants to merge 5 commits into
Onelevenvy:mainfrom
MuFengMuXue:main
Open

perf: eliminate full message-history deep clone in add_messages reducer#30
MuFengMuXue wants to merge 5 commits into
Onelevenvy:mainfrom
MuFengMuXue:main

Conversation

@MuFengMuXue

@MuFengMuXue MuFengMuXue commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The messages reducer (add_messages / add_messages_ref) is the default
reducer for the messages channel in every agent state. Previously every LLM
turn deep-cloned the entire accumulated message history (all content +
tool-call args) just to append a new message — O(n²) over a run.

Changes

  • Reducer signature: ReducerFn changed from
    fn(&JsonValue, &JsonValue) -> JsonValue to fn(JsonValue, &JsonValue) -> JsonValue.
  • BinaryOperatorAggregate::update now hands ownership of the accumulated
    value to the reducer via guard.take(), so existing messages are moved
    instead of deep-cloned (update stays borrowed — usually one new message).
  • add_messages: consumes current, moves existing messages into the
    result, collects remove-ids as borrowed &str (no per-message String
    alloc), and takes a fast path when there are no removals.
  • add_messages_ref kept as a thin forwarder for the derive macro's
    #[channel(messages)] and existing #[channel(reducer = "add_messages_ref")].

Breaking change

Reducers now receive current by value. Custom reducers written as
fn(&JsonValue, &JsonValue) -> JsonValue must change the first parameter to
JsonValue.

Benchmark

Added crates/langgraph-prebuilt/tests/bench_messages.rs (#[ignore]d),
reducer hot path, best-of-3, release:

steps before after speedup
250 29.2ms 0.29ms ~100×
500 124ms 0.64ms ~193×
1000 521ms 1.57ms ~332×

Scaling: quadratic → near-linear.

Testing

  • cargo build --workspace
  • cargo test --workspace ✅ (one pre-existing unrelated failure in
    langgraph-checkpoint-sqlite::test_put_writes_and_pending_writes_round_trip,
    reproduced on clean main)
  • cargo clippy --all-targets --all-features -D warnings
  • cargo fmt --all --check

Note (2026-08-02) — bench corrected

tests/bench_pregel.rs was corrected in 2c5d99c:

  • The growth benches used InMemorySaver, which keeps every checkpoint and retained O(steps²) serialized state (OOM after a few hundred steps). They now use LatestOnlySaver (retains only the newest checkpoint per thread).
  • The multi-step loop bench's conditional routing only sees the node output, so routing on messages.len() always saw a single-element array and silently truncated every run at the recursion limit. It now routes on an explicit count channel and runs the real super-steps.
  • Identical corrected bench as PR perf: defer per-super-step read_channels deep clone in non-streaming mode #31 (6006a43), so whichever PR merges first lands the same file.

The messages reducer is the default for the `messages` channel in every
agent state. Each LLM turn previously deep-cloned the entire accumulated
history (all message content + tool args) — O(n^2) over a run.

Change the reducer signature from `fn(&JsonValue, &JsonValue) -> JsonValue`
to `fn(JsonValue, &JsonValue) -> JsonValue` and have
BinaryOperatorAggregate::update hand ownership of the accumulated value to
the reducer via `guard.take()`, so existing messages are moved instead of
cloned. `update` stays borrowed (usually a single new message).

add_messages now:
- consumes `current` and moves existing messages into the result
- collects remove-ids as borrowed `&str` (no per-message String alloc)
- takes a fast path when there are no removals

A benchmark is added (tests/bench_messages.rs, #[ignore]d) measuring the
reducer hot path: ~100x faster at 250 steps up to ~330x at 1000 steps,
and scaling drops from quadratic to near-linear.

This is a breaking API change for reducers: they now receive `current` by
value.
Checkpoint saves re-encoded and re-inserted a blob row for every channel
on every super-step. With a large static channel (e.g. embedded context)
that meant re-serializing and re-writing the same value each step — pure
write amplification.

save_checkpoint now takes the channel versions as of the start of the run
and derives new_versions = channels whose version moved, passing only the
delta to the saver. SqliteSaver::put writes blob rows only for
new_versions; reads merge version-joined blob values over the checkpoint
row body (extend, not replace) so every channel resolves through the
version join regardless of which checkpoint wrote the blob.

Adds test_incremental_blob_writes (a second checkpoint with one updated
channel writes one new blob row; both new and old checkpoints read back
fully) and a benchmark suite (tests/bench_pregel.rs, #[ignore]d) covering
checkpointed growth and a static-context scenario.

Measured (release, 800 steps, per-step): sqlite linear 12.651->12.230ms
(~3%), static 200KB context 12.723->12.200ms (~4%). The win is small on
these micro-benches because the dominant per-step cost is in-process
state handling (read_channels deep clone), not blob I/O; write
amplification is eliminated, which matters far more for remote savers and
large static channels.
…he multi-step loop measurement

- LatestOnlySaver retains only the newest checkpoint per thread. InMemorySaver
  keeps every checkpoint, so the growth benches retained O(steps²) serialized
  state and OOM'd after a few hundred steps; retention is now O(latest state).
- The multi-step loop's conditional routing only sees the node output, so
  routing on messages.len() always saw a single-element array and the loop ran
  to the recursion limit (25 default) — silently truncating every run. Route on
  an explicit count channel so the loop runs the real target super-steps.
- Same bench content as PR Onelevenvy#31's correction commit (6006a43) so either PR
  merging first lands the identical corrected bench.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant