Skip to content

core: cache turn diff rendering - #27489

Merged
nornagon-openai merged 3 commits into
mainfrom
nornagon/codex/turn-diff-performance
Jun 11, 2026
Merged

core: cache turn diff rendering#27489
nornagon-openai merged 3 commits into
mainfrom
nornagon/codex/turn-diff-performance

Conversation

@nornagon-openai

Copy link
Copy Markdown
Contributor

Summary

Turn diff updates repeatedly rendered and serialized the entire accumulated diff after every apply_patch. The event path also rendered once before updating the tracker solely to test whether a diff existed. In production feedback CODEX-20PW, 2,589 patches across 72 paths produced 401 notifications totaling 441 MB, with the hottest paths patched 518 and 495 times.

This change:

  • replaces the pre-update render with a cheap cached-state check
  • caches each rendered file diff by path and content revision, so an update only invokes Myers for affected paths
  • caches the deterministic aggregate diff so event emission and turn completion reuse it without recomputation
  • preserves invalidation and net-zero clear notifications
  • applies a 100 ms per-file similar timeout; ordinary files complete far below this threshold, while pathological rewrites fall back to a coarse unified hunk that still represents the exact final contents

The 100 ms deadline bounds synchronous tool-completion latency while leaving substantial headroom for normal diffs. The regression test applies the fallback diff through the repository's patch parser and verifies byte-for-byte final contents.

Validation

  • cargo test -p codex-core turn_diff_tracker::tests (14 passed)
  • cargo test -p codex-core tools::events::tests (4 passed)
  • just fix -p codex-core
  • just fmt

Focused coverage verifies that 42 updates across two files perform 42 file renders rather than repeatedly rendering the accumulated set, unchanged paths are not re-diffed, clear events remain correct, and a 48,000-line near-total rewrite returns promptly and applies to the exact expected result. The full codex-core suite was not used as the final gate because an unrelated existing multi-agent test hit a stack overflow when run during investigation.

Bug context

  • Sentry feedback: CODEX-20PW
  • Correlation IDs: 019eb2a9-13d2-74e0-b690-27ee224ffb6d, 019e9ad7-09c3-7cb2-b728-ee3acba103ab

@nornagon-openai

Copy link
Copy Markdown
Contributor Author

<@nornagon> can you break down this branch's changes in codex-rs/core/src/turn_diff_tracker.rs piece by piece and explain them to me?

<@codex>

At a high level, the old tracker stored file contents and regenerated every file’s diff whenever get_unified_diff() was called. The branch turns it into an incrementally maintained cache: applying a delta updates changed files, recomputes only invalidated per-file diffs, and stores the assembled turn diff for subsequent reads.

1. Contents Get Revisions

turn_diff_tracker.rs:20

struct TrackedContent {
    content: String,
    revision: u64,
}

Previously, baseline_by_path and current_by_path stored plain Strings. They now store the content plus a monotonically increasing revision number.

The revision is a cheap content identity. Instead of hashing or comparing multi-megabyte strings to decide whether a cached diff is current, the tracker compares integers.

Revisions are allocated here:

turn_diff_tracker.rs:282

fn tracked_content(&mut self, content: &str) -> TrackedContent {
    let revision = self.next_revision;
    self.next_revision += 1;
    TrackedContent {
        content: content.to_string(),
        revision,
    }
}

Every recorded mutation gets a new revision, even if its text happens to equal an earlier version. That conservatively invalidates the touched file’s cache entry. render_diff() later detects genuinely equal baseline/current text and returns None.

2. Per-File Cache Keys

turn_diff_tracker.rs:40

struct DiffCacheKey {
    left_path: TrackedPath,
    left_revision: Option<u64>,
    right_path: TrackedPath,
    right_revision: Option<u64>,
}

A rendered file diff depends on four facts:

  • The original path
  • The original content revision
  • The final path
  • The final content revision

Both paths are necessary because moves and renames can produce a diff from old/path to new/path.

The revisions are optional because one side may not exist:

  • None → Some(revision): added file
  • Some(revision) → None: deleted file
  • Some(old) → Some(new): updated or moved file

Including environment_id inside TrackedPath also prevents equal filesystem paths in separate environments from sharing a cache entry.

3. New Tracker State

turn_diff_tracker.rs:50

The important additions are:

next_revision: u64,
rendered_diffs: HashMap<DiffCacheKey, Option<String>>,
unified_diff: Option<String>,

rendered_diffs stores each file’s already-rendered Git diff. It stores Option<String>, not just String, because “these versions produce no diff” is also worth caching. That matters for net-zero edits and pure renames.

unified_diff stores the assembled turn-level output. Callers no longer trigger rendering themselves.

rendered_diff_count exists only in tests. It proves that unchanged files do not enter render_diff() again.

4. Updates Maintain the Cache

turn_diff_tracker.rs:93

pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) {
    if !self.valid {
        return;
    }

    if !delta.is_exact() {
        self.invalidate();
        return;
    }

    for change in delta.changes() {
        self.apply_change(environment_id, change);
    }
    self.refresh_unified_diff();
}

Previously, track_delta() only updated the content maps. Rendering happened later whenever a caller requested the diff.

Now it:

  1. Ignores future updates after invalidation.
  2. Applies the exact committed changes.
  3. Refreshes the cached diff once.

This moves the expensive work to the mutation boundary. Any number of later reads, including turn completion, reuse that result.

The early !self.valid return also makes invalidation permanent. Once an inexact delta means the tracker can no longer guarantee correctness, later exact deltas cannot accidentally resurrect a partial diff.

5. Invalidation Clears Observable State

turn_diff_tracker.rs:109

pub fn invalidate(&mut self) {
    self.valid = false;
    self.rendered_diffs.clear();
    self.unified_diff = None;
}

Previously, invalidation only set valid = false, and get_unified_diff() checked that flag.

Now readers consume cached state directly, so invalidation must clear both caches. Setting unified_diff to None is also what lets the event layer emit an empty diff notification when an existing diff is invalidated.

The per-file cache is cleared because none of its entries are useful once the tracker can no longer produce an authoritative result.

6. Reading Is Cheap

turn_diff_tracker.rs:115

pub fn get_unified_diff(&self) -> Option<String> {
    self.unified_diff.clone()
}

pub(crate) fn has_unified_diff(&self) -> bool {
    self.unified_diff.is_some()
}

This is the central behavioral change.

Before, get_unified_diff():

  • Collected every tracked path
  • Sorted them
  • Recomputed every file’s Myers diff
  • Recomputed Git blob hashes
  • Rebuilt the aggregate string

Now it clones an already-computed result.

has_unified_diff() supports the apply-patch event path’s pre-update check without cloning or rendering anything.

One nuance: because the public method returns an owned String, get_unified_diff() still copies the aggregate bytes. It avoids all diff computation and assembly, but not the final owned-string copy required by the existing API.

7. Incremental Refresh

turn_diff_tracker.rs:123

The beginning is intentionally similar to the old renderer:

  • Determine valid rename pairs.
  • Collect baseline and current paths.
  • Sort by displayed path for deterministic output.
  • Avoid rendering rename destinations twice.

The cache logic begins here:

let mut previous_diffs = std::mem::take(&mut self.rendered_diffs);
let mut rendered_diffs = HashMap::new();

mem::take moves the old cache out without cloning it. The tracker temporarily gets an empty map.

For each logical file, it creates a cache key from paths and revisions:

turn_diff_tracker.rs:155

let key = DiffCacheKey {
    left_path: left_path.clone(),
    left_revision: left_content.map(|content| content.revision),
    right_path: right_path.clone(),
    right_revision: right_content.map(|content| content.revision),
};

Then it either reuses the old result or renders:

let rendered = previous_diffs.remove(&key).unwrap_or_else(|| {
    self.render_diff(...)
});

Suppose a turn has already changed a.rs, b.rs, and c.rs, then another patch touches only c.rs:

  • a.rs has the same paths and revisions, so its rendered string is reused.
  • b.rs is also reused.
  • c.rs has a new current revision, so only it enters Myers again.

The refreshed entries are inserted into a new map. Any old entries that no longer correspond to current state are naturally discarded.

Finally:

self.rendered_diffs = rendered_diffs;
self.unified_diff = (!aggregated.is_empty()).then_some(aggregated);

The aggregate is still rebuilt after each committed patch because existing event semantics require the complete updated turn diff. The expensive file comparison and hashing are reused; aggregate concatenation remains linear in the current output size.

This also handles net-zero state correctly. If every file’s baseline equals its final content, aggregated stays empty and unified_diff becomes None.

8. Rendering Is Time-Bounded

turn_diff_tracker.rs:356

Old code:

similar::TextDiff::from_lines(old, new)

New code:

let mut config = similar::TextDiff::configure();
config.timeout(DIFF_TIMEOUT);
let unified = config.diff_lines(old, new);

The timeout is defined as 100 ms:

turn_diff_tracker.rs:16

Myers can become extremely slow for large, mostly rewritten files. The observed 48,000-line case spent roughly 10.6 seconds inside the algorithm. With the timeout, similar stops searching for a minimal edit sequence and emits a coarser replacement-style diff.

“Coarser” only means fewer intelligently matched unchanged regions. It does not mean approximate content: applying the resulting unified diff still produces the exact final file. The regression test verifies that through the repository’s patch implementation.

The deadline is per newly rendered file, not per entire delta. A patch changing several pathological files could consume roughly 100 ms per file.

9. Removed Rendering Wrappers

The old render_path_diff() and render_rename_diff() helpers were deleted.

They merely selected baseline/current contents before calling render_diff(). Cache construction already needs those paths, contents, and revisions together, so retaining the wrappers would split one operation across unnecessary layers.

Resulting Execution Flow

After this branch, one patch follows this path:

apply_patch completes
    ↓
track_delta()
    ↓
update baseline/current revisions for touched paths
    ↓
refresh_unified_diff()
    ↓
reuse cached diffs for unchanged paths
render touched paths, each capped at 100 ms
reassemble deterministic aggregate
    ↓
event code reads cached aggregate
    ↓
turn completion later reads the same cached aggregate

The key improvement is that work scales primarily with files changed by the latest patch, rather than repeatedly running Myers over every file changed during the entire turn.

@nornagon-openai
nornagon-openai marked this pull request as ready for review June 10, 2026 23:30
@nornagon-openai
nornagon-openai requested a review from a team as a code owner June 10, 2026 23:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56421478f4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

.await;
}

#[tokio::test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the new tests out of the oversized module

Adding these tests inline keeps growing events.rs after it crosses ~800 LoC; repo guidance says oversized Rust modules should get new code in a new module absent a documented reason. Please move this test coverage to a sibling *_tests.rs file or another scoped module. guidance

Useful? React with 👍 / 👎.


rx_event.recv().await.expect("item completed event");
let unified_diff = loop {
let event = rx_event.recv().await.expect("turn diff event");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the wait for TurnDiff events

If a regression causes emit_patch_end to skip TurnDiff, this loop never terminates because the session side of the channel is still held by the test, so recv().await won't return None; the test will hang until the whole test runner times out. Wrap the wait in a timeout or assert after a bounded number of events.

Useful? React with 👍 / 👎.

Comment on lines +368 to +371
#[cfg(test)]
fn rendered_diff_count(&self) -> usize {
self.rendered_diff_count.get()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep test-only accessors out of implementation

This #[cfg(test)] accessor exists only so tests can inspect private state, leaving test-only API in the main implementation despite repo guidance to keep unit tests in dedicated *_tests.rs files and avoid test-only functions there. Prefer public behavior assertions or test-file scaffolding. guidance

Useful? React with 👍 / 👎.

.expect("complete rewrite should produce a diff");

assert!(
started.elapsed() < Duration::from_secs(2),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid wall-clock assertions in large diff test

On slower CI machines or under load, this hard 2-second wall-clock assertion can fail even when the timeout behavior is functionally correct, since the test still builds and renders a 48k-line rewrite before checking elapsed time. That turns a performance guard into a flaky codex-core test; prefer asserting the generated diff behavior or using a much less environment-sensitive bound.

Useful? React with 👍 / 👎.

@nornagon-openai
nornagon-openai merged commit b389b95 into main Jun 11, 2026
31 checks passed
@nornagon-openai
nornagon-openai deleted the nornagon/codex/turn-diff-performance branch June 11, 2026 00:17
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants