Skip to content

perf(rpc): add progressive block trace caching - #4021

Closed
danielntmd wants to merge 6 commits into
mainfrom
danielntmd/progressive-trace-cache
Closed

danielntmd wants to merge 6 commits into
mainfrom
danielntmd/progressive-trace-cache

Conversation

@danielntmd

@danielntmd danielntmd commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

TraceTransaction currently executes every transaction in a finalized block on a cache miss, even when the requested transaction appears near the beginning.

This PR caches successful tracing progress from the beginning of each block (a prefix). Later requests reconstruct the state at the cached point and execute only the remaining transactions through the requested target (the missing suffix). Only one request extends a block at a time, while different blocks can be traced concurrently.

Successful execution publishes a new cache record while existing cached traces remain available to readers.

Workflow

lookup(block, target)
├─ target cached → return
├─ extension active → wait or cancel, then retry lookup
└─ target missing → reconstruct state and execute missing suffix
                     ├─ success → append, publish, wake waiters
                     └─ failure → preserve record, wake waiters

Cache Invariants

  • A cache record is a contiguous, append-only prefix of successful transaction traces.
  • At most one flight extends a block; concurrent callers wait and recheck the record.
  • A successful extension is published atomically, while failure or panic leaves the record unchanged.
  • Empty blocks are not cached.

Tracing Behavior

  • TraceTransaction executes only through the requested transaction instead of tracing the complete block.
  • TraceBlockTransactions completes any existing prefix and returns the full block.
  • Requesting initial reads replays the full block if they are missing from the cache.

Benchmarks

Cold starknet_traceTransaction at 100 VUs

Broad corpus sample size: 1,000
Everything else: 200

Block corpus Target Throughput Δ Baseline avg PR avg Latency Δ CPU/request Δ
Broad Beginning +637.40% 7,460.6 ms 972.0 ms -86.97% -86.83%
Broad Middle +85.73% 7,469.8 ms 4,007.6 ms -46.35% -46.40%
Broad End +0.50% 7,478.6 ms 7,438.8 ms -0.53% -0.55%
1 transaction Beginning +0.18% 700.8 ms 704.0 ms +0.46% +1.73%
1 transaction Middle +4.08% 694.9 ms 685.8 ms -1.31% -1.03%
1 transaction End -0.60% 698.7 ms 666.3 ms -4.63% -2.62%
10 transactions Beginning +620.60% 7,132.4 ms 801.6 ms -88.76% -86.85%
10 transactions Middle +81.34% 6,887.2 ms 3,780.5 ms -45.11% -45.31%
10 transactions End -0.19% 7,121.4 ms 7,122.2 ms +0.01% +0.24%
>20 transactions Beginning +1,631.16% 14,871.4 ms 726.8 ms -95.11% -94.82%
>20 transactions Middle +82.75% 14,804.1 ms 7,953.5 ms -46.27% -45.45%
>20 transactions End +0.21% 14,864.8 ms 14,926.2 ms +0.41% +0.78%

Interleaved traceTransaction -> traceBlockTransactions

Sample size: 200

Block corpus VUs Throughput Δ Baseline avg PR avg Latency Δ CPU/pair Δ
Broad 1 -4.69% 448.6 ms 470.7 ms +4.92% +5.83%
Broad 50 -4.56% 3,249.3 ms 3,437.4 ms +5.79% +3.87%
1 transaction 1 +1.33% 47.4 ms 46.7 ms -1.31% +0.39%
1 transaction 50 -4.73% 389.3 ms 401.3 ms +3.09% +1.00%
10 transactions 1 -0.91% 593.5 ms 598.9 ms +0.92% +1.39%
10 transactions 50 -2.90% 4,074.9 ms 4,334.4 ms +6.37% +3.30%
>20 transactions 1 -0.43% 1,093.1 ms 1,097.8 ms +0.43% +0.58%
>20 transactions 50 +1.21% 8,533.2 ms 8,756.5 ms +2.62% -0.11%

PR Type

Enhancement, Tests


Description

  • Implement progressive block trace caching.

    • Executes only missing trace suffix.
    • Reconstructs state from cached trace checkpoints.
  • Add per-block trace flight coordination.

    • Prevents concurrent extensions of identical blocks.
    • Wakes waiters upon successful cache extension.
  • Optimize memory usage in trace adaptations.

    • Uses pointers to avoid trace data copies.
    • Avoids caching empty block initial reads.
  • Refactor RPC handlers for targeted tracing.


File Walkthrough

Relevant files
Enhancement
5 files
adapt_trace.go
Optimize VM initial reads adaptation by reducing copies   
+9/-13   
simulation.go
Add helper function for empty initial reads                           
+9/-0     
trace.go
Refactor tracing to support progressive execution targets
+94/-127
trace_cache.go
Implement custom trace cache with flight coordination       
+164/-0 
trace_progressive.go
Implement state reconstruction and progressive tracing logic
+215/-0 
Configuration changes
1 files
handlers.go
Integrate custom progressive block trace cache                     
+4/-9     
Tests
3 files
trace_cache_test.go
Add unit tests for progressive trace cache                             
+103/-0 
trace_progressive_test.go
Add unit tests for progressive block tracing                         
+441/-0 
trace_test.go
Update trace tests for progressive caching behavior           
+157/-84

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.87854% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.27%. Comparing base (e85a15e) to head (6e6b2f2).
⚠️ Report is 30 commits behind head on main.

Files with missing lines Patch % Lines
rpc/v10/trace_progressive.go 84.74% 18 Missing ⚠️
rpc/v10/trace.go 93.22% 4 Missing ⚠️
rpc/v10/adapt_trace.go 62.50% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4021      +/-   ##
==========================================
+ Coverage   79.19%   79.27%   +0.07%     
==========================================
  Files         464      468       +4     
  Lines       35741    35961     +220     
==========================================
+ Hits        28305    28507     +202     
- Misses       7427     7445      +18     
  Partials        9        9              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread rpc/v10/handlers.go Outdated
@danielntmd
danielntmd force-pushed the danielntmd/progressive-trace-cache branch from 58d79fc to 6ab7d60 Compare September 3, 2026 11:36
@danielntmd
danielntmd marked this pull request as ready for review September 3, 2026 11:41
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 6m 39s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md)
  • Review rpc/v10/trace_cache.go (new cache structure, concurrency)
  • Review rpc/v10/trace_progressive.go (progressive execution logic)
  • Review rpc/v10/trace.go (handler changes)
  • Review rpc/v10/trace_cache_initial_reads.go
  • Review rpc/v10/handlers.go / adapt_trace.go
  • Review tests for coverage/flakiness
  • Post inline comments for issues found
  • Post final summary

I focused on concurrency correctness in the new blockTraceCache/traceFlight design and the state-checkpoint reconstruction in trace_progressive.go, since that's where a bug would be most consequential (returning a wrong trace or corrupting a cached record).

Findings: no blockers or bugs. The single-flight-per-block design, panic-safe waiter release (defer work.abort()), the append-only record with capacity-limited slice views (preventing torn reads via shared backing arrays), and the "record survives LRU eviction until re-published" behavior are all correctly implemented, and each of these edge cases has a dedicated test (TestProgressiveTraceCacheWaiterCancellationDoesNotCancelExtension, TestProgressiveTraceCachePanicClearsInflight, TestProgressiveTraceCacheAllowsRecordEvictionDuringFlight, TestProgressiveTraceCacheFailureRetainsPublishedPrefix). Lock ordering between the cache-level mutex and per-record RWMutex is consistent (no nested-lock cycle), and pending.State/checkpoint reconstruction (checkpointFromTraces + checkpointClasses) correctly rebuilds intermediate state from cached per-transaction state diffs, verified in TestProgressiveTraceCacheExtendsPrefix via a gomock.Cond on the reconstructed state.

Two non-blocking nits posted inline:

  • rpc/v10/trace.go:417-428 — the new single-flight invariant only covers the local progressive-tracing path; concurrent requests for the same uncached feeder-gateway-sourced block still race and can trigger redundant gateway fetches (pre-existing gap, not introduced by this PR).
  • rpc/v10/trace_progressive.go:100-108checkpointFromTraces/checkpointClasses fully re-derive the merged state diff from scratch on every extension rather than incrementally, which is O(prefix length) per call (fine for typical usage, worth a comment or follow-up if adversarial one-at-a-time extension patterns show up).

One thing worth double-checking with the author rather than a code issue: the VM is now always invoked with ReturnInitialReads: true, even for unflagged single-transaction traces — intentional per the PR description, and the benchmarks show no meaningful regression for the single-transaction cases, so this looks like an accepted tradeoff rather than an oversight.

Note: I wasn't able to run go build/go test -race in this environment (no tool access to execute Go commands), so this review is based on static reading of the diff and existing tests rather than a live race-detector run.

Comment thread rpc/v10/trace_progressive.go Outdated
Comment thread rpc/v10/trace.go Outdated
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 4m 16s —— View job


Review complete

  • Gather context (diff since last review, new commit ff917ed53)
  • Review rpc/v10/trace.go changes (feeder gateway InitialReads wire format)
  • Review rpc/v10/trace_test.go new assertion
  • Cross-check against pre-PR (origin/main) behavior for wire-format parity
  • Build/vet rpc/v10 package
  • Post summary

This PR was synchronized with a new commit (ff917ed53, "fix(rpc): preserve feeder initial reads wire format") on top of the previously-reviewed 6ab7d603e. Only rpc/v10/trace.go and rpc/v10/trace_test.go changed; the rest of the PR (cache/single-flight design in trace_cache.go, trace_progressive.go) is unchanged from the earlier review, which found no blockers.

New commit review: correct, no issues.

  • traceFinalisedBlockSource (rpc/v10/trace.go:472-476) now builds the feeder-gateway InitialReads as &InitialReads{} (nil slices) instead of emptyInitialReads() (empty, non-nil slices). Since InitialReads's fields have no omitempty tag, this is a real wire-format difference (null vs []), and matches the pre-PR behavior on origin/main (InitialReads: &InitialReads{} at the equivalent gateway branch), so this restores backward compatibility for clients relying on the historical shape.
  • The local-execution empty-block path (rpc/v10/trace.go:439-442) intentionally still uses emptyInitialReads() (empty arrays) — that's a genuine "zero reads occurred" case rather than "the gateway doesn't tell us," so keeping it distinct from the gateway's null-shaped placeholder is correct, not an inconsistency.
  • The new test assertion in rpc/v10/trace_test.go:194-198 directly locks in the fix by asserting require.Equal(t, &rpcv10.InitialReads{}, withReads.InitialReads) against real feeder testdata fixtures — good regression coverage for exactly the field that broke.
  • storeComplete still requires non-nil InitialReads (trace_cache.go); &InitialReads{} satisfies that, so no nil-pointer risk was introduced.

Severity: none (nit/important/blocker) — no new issues found. The two nits from the earlier review round (feeder-gateway path lacking single-flight coordination, and checkpointFromTraces recomputing from scratch on each extension) were both already discussed inline and acknowledged by the author as pre-existing/out-of-scope and an accepted trade-off, respectively — no further action needed there.

Note: go build ./rpc/v10/... and go vet ./rpc/v10/... both pass. I was not able to run go test in this environment (command required interactive approval that wasn't available), so this is based on static review plus the build/vet checks above rather than a live test run.

Collect initial reads on demand, replaying the block when cached traces lack requested reads. Shape responses at the RPC boundary and share finalised trace routing.

Publish immutable cache records with per-block flight coordination. Trim test fixtures and strengthen checkpoint and prefix preservation coverage.
@danielntmd
danielntmd marked this pull request as ready for review September 8, 2026 06:38
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Review in progress

  • Gather context (diff vs origin/main, CLAUDE.md, changed files)
  • Review rpc/v10/trace_cache.go (cache/flight structure, concurrency)
  • Review rpc/v10/trace_progressive.go (progressive execution logic)
  • Review rpc/v10/trace.go changes (handler wiring)
  • Review rpc/v10/adapt_trace.go, handlers.go, simulation.go
  • Review tests (trace_cache_test.go, trace_progressive_test.go, trace_test.go)
  • Build/vet rpc/v10
  • Post inline comments for any new issues found
  • Post final summary

Comment thread rpc/v10/simulation.go
Comment on lines +163 to +170
func emptyInitialReads() *InitialReads {
return &InitialReads{
Storage: []StorageEntry{},
Nonces: []NonceEntry{},
ClassHashes: []ClassHashEntry{},
DeclaredContracts: []DeclaredContractEntry{},
}
}

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.

Why not return by value? (reduce the extra alloc)

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.

Also, being used only twice, you could consider inlining it. Up to you!

@danielntmd danielntmd Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why not return by value? (reduce the extra alloc)

rpc/v10/simulation.go:121

type TraceBlockTransactionsResponse struct {
      Traces       []TracedBlockTransaction `json:"traces"`
      InitialReads *InitialReads            `json:"initial_reads"`
}

InitialReads is a pointer, we could change the field to a value, but then we would need to track absence separately since we currently use nil.

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.

You can return by value and still treated as a pointer via the use of new(...).

Wherever you call emptyInitialReads() will become new(emptyInitialReads())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here are the suggested code shapes and their respective escaped diagnostics and benchmarks.

// Current:
func emptyInitialReads() *InitialReads {
    return &InitialReads{ /* four empty slices */ }
}

response.InitialReads = emptyInitialReads()
// Proposed value-returning helper:
func emptyInitialReads() InitialReads {
    return InitialReads{ /* same four empty slices */ }
}

// Explicit address:
reads := emptyInitialReads()
response.InitialReads = &reads
// Or the proposed new(...) construction:
response.InitialReads = new(emptyInitialReads())
Shape B/op allocs/op
Current pointer return 96 1
Value return, then &reads 96 1
Value return, then new(...) 96 1

The problem is that both constructions escape through the response returned by traceFinalisedBlock, so returning by value and wrapping it in new retains allocation. We can avoid this allocation for the empty block path when no reads are requested by leaving InitialReads as nil, as such:

response := TraceBlockTransactionsResponse{
    Traces: []TracedBlockTransaction{},
}
if returnInitialReads {
    response.InitialReads = emptyInitialReads()
}
return response, defaultExecutionHeader(), nil

This removes the allocation for unflagged empty-block requests while preserving the external response format. The requested-reads path still allocates, as it did before this PR.

Comment thread rpc/v10/trace_cache.go
Comment on lines +105 to +129
// storeComplete takes ownership of the response containers. InitialReads must be non-nil.
func (c *blockTraceCache) storeComplete(
blockHash felt.Felt,
response TraceBlockTransactionsResponse,
) {
record := &blockTraceRecord{
traces: response.Traces,
initialReads: response.InitialReads,
complete: true,
}
c.mu.Lock()
defer c.mu.Unlock()
c.records.Add(blockHash, record)
}

func (c *blockTraceCache) record(blockHash felt.Felt) (*blockTraceRecord, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.records.Get(blockHash)
}

func (c *blockTraceCache) finishLocked(blockHash felt.Felt, flight chan struct{}) {
delete(c.flights, blockHash)
close(flight)
}

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.

small perf: blockHash should be passed by reference, @infrmtcs please explain why

@danielntmd danielntmd Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied. @infrmtcs no need. Passing by value can introduce copies between function calls.

Measured over 10 × 1-second samples per revision using direct Go handler calls with fixed fixtures.

Cache-hit handler Before After Time saved per request
TraceTransaction 311.90 ns/op 310.30 ns/op 1.60 ns (0.51%)
TraceBlockTransactions 174.40 ns/op 171.90 ns/op 2.50 ns (1.43%)

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.

Yes, really small. Just to have the style set to pass by reference value types bigger than one word and they are meant to be read only.

You can pass by value if mutating

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Review in progress

  • Gather context (diff since last review, new commits 0c2c85558, 2c17da6be)
  • Review rpc/v10/trace_cache.go (refactored cache/flight structure)
  • Review rpc/v10/trace_progressive.go / trace.go changes
  • Review rpc/v10/simulation.go (empty initial reads optimization)
  • Review tests for new behavior
  • Build/vet rpc/v10
  • Post final summary

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Review in progress

  • Gather context (diff since last review, new commits 0c2c85558, 2c17da6be, 6f499c949)
  • Review rpc/v10/trace_cache.go (refactored cache/flight structure)
  • Review rpc/v10/trace_progressive.go / trace.go changes
  • Review rpc/v10/simulation.go (empty initial reads optimization)
  • Review tests for new behavior
  • Build/vet rpc/v10
  • Post final summary

@danielntmd

danielntmd commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Measured over 10 × 1-second samples per revision using direct Go handler calls with fixed fixtures.

Handler workload Before (ns/op) After (ns/op) Time saved per call
Block cache hit 171.90 171.15 0.75 ns (0.44%)
Extend prefix, 16 entries/category 14,927.50 14,827.50 100.00 ns (0.67%)
Extend prefix, 256 entries/category 206,230.00 204,586.50 1,643.50 ns (0.80%)
Initial reads, 16 entries/category 2,114.00 2,043.50 70.50 ns (3.33%)
Initial reads, 256 entries/category 15,818.50 14,782.00 1,036.50 ns (6.55%)

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 18m 15s —— View job


Review complete

  • Gather context (diff since last completed review, commits 0c2c85558..6e6b2f242)
  • Review rpc/v10/trace_cache.go (refactored cache/flight structure)
  • Review rpc/v10/trace_progressive.go (progressive execution logic)
  • Review rpc/v10/trace.go changes (handler wiring)
  • Review rpc/v10/adapt_trace.go, simulation.go
  • Review tests for new/changed behavior
  • Build/vet rpc/v10
  • Post inline comments for any new issues found
  • Post final summary

This round covers four commits since the last completed review: 0c2c85558 (refactor: simplify progressive trace caching), 2c17da6be (skip unrequested empty-block initial-reads allocation), 6f499c949 (pass cache helper hashes by pointer), 6e6b2f242 (reduce temporary trace data copies).

No blockers or new bugs found. The 0c2c85558 refactor is a genuine simplification: blockTraceRecord dropped its per-record RWMutex and blockTraceRecordView indirection in favor of treating published records as fully immutable (a new *blockTraceRecord is built and swapped in under the cache-level mu on every commit), and finalisedBlockTraceSource was inlined into traceFinalisedBlock/traceProgressiveBlock via a unified traceTarget parameter. This is a net reduction in moving parts while preserving the invariants already covered by tests:

  • Capacity-limited slices (r.traces[:len(r.traces):len(r.traces)]) still force append in commit() to allocate a fresh backing array, so previously-returned prefixes can never be mutated by a later extension — verified by TestBlockTraceRecordAppendPreservesPublishedPrefix.
  • Requesting RETURN_INITIAL_READS against a cached-but-read-less complete record now correctly discards the stale record's prefix in lookupOrStart (rpc/v10/trace_cache.go:93-96) and does a full replay, while leaving the original record visible to concurrent read-less callers until the replay commits — verified by the new cache-state assertions in TestBlockTraceCacheLookupTransitions (abort-then-still-hit, then successful commit-then-hit-with-reads).
  • traceProgressiveBlock now rejects returnInitialReads for any non-last-transaction target up front (trace_progressive.go:31-34), consistent with "initial reads only describe a complete block trace."

2c17da6be and 6f499c949 are small, low-risk perf tweaks (skip allocating emptyInitialReads() when the flag isn't set; pass *felt.Felt instead of copying felt.Felt by value through the cache helpers) with no behavioral change — both are exercised by existing/updated tests (TestTraceFinalisedEmptyBlockReturnsWithoutCaching).

6e6b2f242 replaces for _, x := range slice with for i := range slice { x := &slice[i] } in adaptVMInitialReads and mergeRPCStateDiff purely to avoid copying each struct element twice (once into the range variable, once into the destination). Since all destination fields are copied by value out of *x immediately, this introduces no aliasing risk.

Verified go build ./rpc/v10/... and go vet ./rpc/v10/... both pass cleanly. I was not able to run go test in this environment (command required interactive approval that wasn't available), so this review is based on static reading of the diff plus the build/vet checks above.

The two nits raised in earlier rounds (feeder-gateway path lacking single-flight coordination, checkpointFromTraces recomputing from scratch per extension) are unchanged by this batch of commits and were already acknowledged by the author as pre-existing/out-of-scope and an accepted trade-off respectively — no further action needed.
· branch: danielntmd/progressive-trace-cache

@rodrodros
rodrodros requested review from rodrodros and removed request for infrmtcs September 11, 2026 14:05
@danielntmd

Copy link
Copy Markdown
Contributor Author

Closing for stacked PR #4065

@danielntmd danielntmd closed this Sep 15, 2026
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.

2 participants