Skip to content

wrap RawAuctionData in Arc for efficient sharing#4392

Open
ashleychandy wants to merge 5 commits into
cowprotocol:mainfrom
ashleychandy:perf/arc-raw-auction-data
Open

wrap RawAuctionData in Arc for efficient sharing#4392
ashleychandy wants to merge 5 commits into
cowprotocol:mainfrom
ashleychandy:perf/arc-raw-auction-data

Conversation

@ashleychandy
Copy link
Copy Markdown
Contributor

Description

Fixes a performance issue where RawAuctionData was fully cloned on every current_auction() call in the run loop. Wrapping it in Arc makes the clone operation a reference counter increment instead of a deep copy of all orders, prices, and metadata.

Changes

  • Wrap RawAuctionData in Arc within the solvable orders cache
  • Update current_auction() to return Arc<RawAuctionData> and use cheap Arc::clone()
  • Update persistence methods to accept &Arc<RawAuctionData> and use .as_ref().clone() when cloning is necessary
  • Clone auction data only when converting to domain::Auction or DTO (once per auction vs. every cache access)

@ashleychandy ashleychandy requested a review from a team as a code owner May 10, 2026 09:38
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces Arc wrapping for RawAuctionData to improve data sharing efficiency within the autopilot service. Feedback suggests avoiding the use of &Arc as a function parameter to reduce unnecessary indirection and simplify the API. Additionally, it was noted that deep clones of auction collections are still being performed in the run loop, and refactoring the domain::Auction struct to use Arc for its internal fields would better achieve the intended performance gains.

Comment thread crates/autopilot/src/infra/persistence/mod.rs Outdated
Comment thread crates/autopilot/src/run_loop.rs Outdated
Comment on lines +305 to +307
orders: auction.orders.clone(),
prices: auction.prices.clone(),
surplus_capturing_jit_order_owners: auction.surplus_capturing_jit_order_owners.clone(),
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.

high

These lines perform deep clones of the orders, prices, and surplus_capturing_jit_order_owners collections on every auction cycle. While this is necessary because domain::Auction expects owned data, it significantly limits the performance gains of wrapping RawAuctionData in an Arc. To fully achieve the goal of efficient sharing, consider refactoring the domain::Auction struct to use Arc for its collection fields, allowing these to be cheap pointer increments instead of deep copies.

@ashleychandy
Copy link
Copy Markdown
Contributor Author

Hey @MartinquaXD, thanks for the input on the last PR. I’d like to learn the proper way to approach these kinds of optimizations going forward. Could you point me toward the usual steps/process you’d expect here, like how to benchmark it properly and validate that the change is actually meaningful?

@jmg-duarte
Copy link
Copy Markdown
Contributor

Could you point me toward the usual steps/process you’d expect here, like how to benchmark it properly and validate that the change is actually meaningful?

Sadly, we need to measure this in production, meaning that you can't really provide the benchmarks out of the box.
For very obvious improvements, like the iteration reduction, we can merge them confidently that at least things won't get worse.

But in general, we need to measure them before and after, all that not before ensuring they're correct

@ashleychandy
Copy link
Copy Markdown
Contributor Author

But in general, we need to measure them before and after, all that not before ensuring they're correct

Thanks @jmg-duarte , that clears things up. I'll make sure to frame future optimization PRs more carefully and justify claims properly.

@metalurgical
Copy link
Copy Markdown
Contributor

@ashleychandy

The deep clone appears inevitable currently, but it can be delayed until the end.

Try applying this diff to your PR.

Diff
diff --git crates/autopilot/src/infra/persistence/dto/auction.rs crates/autopilot/src/infra/persistence/dto/auction.rs
index fd75b6d84..6e9aa5393 100644
--- crates/autopilot/src/infra/persistence/dto/auction.rs
+++ crates/autopilot/src/infra/persistence/dto/auction.rs
@@ -22,11 +22,7 @@ pub fn from_domain(auction: &domain::RawAuctionData) -> RawAuctionData {
             .iter()
             .map(|(key, value)| (**key, value.get().0))
             .collect(),
-        surplus_capturing_jit_order_owners: auction
-            .surplus_capturing_jit_order_owners
-            .iter()
-            .copied()
-            .collect(),
+        surplus_capturing_jit_order_owners: auction.surplus_capturing_jit_order_owners.to_vec(),
     }
 }
 
diff --git crates/autopilot/src/infra/persistence/mod.rs crates/autopilot/src/infra/persistence/mod.rs
index 3838e2a09..b1b90b19c 100644
--- crates/autopilot/src/infra/persistence/mod.rs
+++ crates/autopilot/src/infra/persistence/mod.rs
@@ -179,7 +179,7 @@ impl Persistence {
             return;
         };
         tokio::task::spawn(async move {
-            let auction_dto = dto::auction::from_domain(&auction);
+            let auction_dto = dto::auction::from_domain(auction.as_ref());
             match s3.upload(id.to_string(), auction_dto).await {
                 Ok(key) => tracing::info!(?key, "uploaded auction to s3"),
                 Err(err) => tracing::warn!(?err, "failed to upload auction to s3"),
diff --git crates/autopilot/src/run_loop.rs crates/autopilot/src/run_loop.rs
index c47309077..338a0c6e4 100644
--- crates/autopilot/src/run_loop.rs
+++ crates/autopilot/src/run_loop.rs
@@ -88,6 +88,12 @@ pub struct Probes {
     pub startup: Arc<Option<AtomicBool>>,
 }
 
+#[derive(Debug)]
+struct CutAuction {
+    id: Id,
+    auction: Arc<domain::RawAuctionData>,
+}
+
 pub struct RunLoop {
     config: Config,
     eth: infra::Ethereum,
@@ -141,7 +147,7 @@ impl RunLoop {
     }
 
     pub async fn run_forever(self, mut control: ShutdownController) {
-        let mut last_auction = None;
+        let mut last_auction: Option<Arc<domain::RawAuctionData>> = None;
         let mut last_block = None;
 
         let self_arc = Arc::new(self);
@@ -255,28 +261,35 @@ impl RunLoop {
     async fn next_auction(
         &self,
         start_block: BlockInfo,
-        prev_auction: &mut Option<domain::Auction>,
+        prev_auction: &mut Option<Arc<domain::RawAuctionData>>,
         prev_block: &mut Option<B256>,
     ) -> Option<domain::Auction> {
         // wait for appropriate time to start building the auction
-        let auction = self.cut_auction().await?;
-        tracing::trace!(auction_id = ?auction.id, "auction cut");
+        let CutAuction { id, auction } = self.cut_auction().await?;
+        tracing::trace!(auction_id = ?id, "auction cut");
 
         // Only run the solvers if the auction or block has changed.
-        let previous = prev_auction.replace(auction.clone());
-        if previous.as_ref() == Some(&auction)
+        let previous = prev_auction.replace(Arc::clone(&auction));
+        if previous.as_deref() == Some(auction.as_ref())
             && prev_block.replace(start_block.hash) == Some(start_block.hash)
         {
             return None;
         }
 
-        observe::log_auction_delta(&previous, &auction, &start_block);
+        observe::log_raw_auction_delta(id, previous.as_deref(), auction.as_ref(), &start_block);
         self.probes.liveness.auction();
         Metrics::auction_ready(start_block.observed_at);
-        Some(auction)
+
+        Some(domain::Auction {
+            id,
+            block: auction.block,
+            orders: auction.orders.clone(),
+            prices: auction.prices.clone(),
+            surplus_capturing_jit_order_owners: auction.surplus_capturing_jit_order_owners.clone(),
+        })
     }
 
-    async fn cut_auction(&self) -> Option<domain::Auction> {
+    async fn cut_auction(&self) -> Option<CutAuction> {
         let Some(auction) = self.solvable_orders_cache.current_auction().await else {
             tracing::debug!("no current auction");
             return None;
@@ -290,7 +303,8 @@ impl RunLoop {
         Metrics::auction(id);
 
         // always update the auction because the tests use this as a readiness probe
-        self.persistence.replace_current_auction_in_db(id, &auction);
+        self.persistence
+            .replace_current_auction_in_db(id, auction.as_ref());
         self.persistence
             .upload_auction_to_s3(id, Arc::clone(&auction));
 
@@ -300,13 +314,8 @@ impl RunLoop {
             tracing::debug!("skipping empty auction");
             return None;
         }
-        Some(domain::Auction {
-            id,
-            block: auction.block,
-            orders: auction.orders.clone(),
-            prices: auction.prices.clone(),
-            surplus_capturing_jit_order_owners: auction.surplus_capturing_jit_order_owners.clone(),
-        })
+
+        Some(CutAuction { id, auction })
     }
 
     #[instrument(skip_all)]
@@ -1085,37 +1094,61 @@ pub mod observe {
         std::collections::HashSet,
     };
 
-    pub fn log_auction_delta(
-        previous: &Option<domain::Auction>,
-        current: &domain::Auction,
+    fn log_order_delta<I, J>(
+        id: domain::auction::Id,
+        previous: I,
+        current: J,
         start_block: &BlockInfo,
-    ) {
-        let previous_uids = match previous {
-            Some(previous) => previous
-                .orders
-                .iter()
-                .map(|order| order.uid)
-                .collect::<HashSet<_>>(),
-            None => HashSet::new(),
-        };
-        let current_uids = current
-            .orders
-            .iter()
-            .map(|order| order.uid)
-            .collect::<HashSet<_>>();
+    ) where
+        I: IntoIterator<Item = domain::OrderUid>,
+        J: IntoIterator<Item = domain::OrderUid>,
+    {
+        let previous_uids = previous.into_iter().collect::<HashSet<_>>();
+        let current_uids = current.into_iter().collect::<HashSet<_>>();
         let added = current_uids.difference(&previous_uids);
         let removed = previous_uids.difference(&current_uids);
         tracing::debug!(
-            id = current.id,
+            id,
             added = ?added,
             "New orders in auction"
         );
         tracing::debug!(
-            id = current.id,
+            id,
             removed = ?removed,
             "Orders no longer in auction"
         );
-        tracing::debug!(auction_id = current.id, ?start_block);
+        tracing::debug!(auction_id = id, ?start_block);
+    }
+
+    pub fn log_raw_auction_delta(
+        id: domain::auction::Id,
+        previous: Option<&domain::RawAuctionData>,
+        current: &domain::RawAuctionData,
+        start_block: &BlockInfo,
+    ) {
+        log_order_delta(
+            id,
+            previous
+                .into_iter()
+                .flat_map(|auction| auction.orders.iter().map(|order| order.uid)),
+            current.orders.iter().map(|order| order.uid),
+            start_block,
+        );
+    }
+
+    pub fn log_auction_delta(
+        previous: &Option<domain::Auction>,
+        current: &domain::Auction,
+        start_block: &BlockInfo,
+    ) {
+        log_order_delta(
+            current.id,
+            previous
+                .iter()
+                .flat_map(|auction| auction.orders.iter().map(|order| order.uid)),
+            current.orders.iter().map(|order| order.uid),
+            start_block,
+        );
     }
 
     pub fn bids(bids: &[domain::competition::Bid<Unscored>]) {
     

@ashleychandy
Copy link
Copy Markdown
Contributor Author

Hey @metalurgical , thanks for the suggestion, really appreciate it. Carrying the Arc through makes the ownership flow much cleaner, and we now only clone when we actually need a concrete domain::Auction for solver consumption.

@ashleychandy
Copy link
Copy Markdown
Contributor Author

Hey @jmg-duarte, here are the benchmark results for the Arc<RawAuctionData> migration.

Branch:
ashley/benchmark-arc-raw-auction-data

Benchmark Results

Ran cargo bench -p autopilot --bench auction_conversion_benchmark comparing the old (by-value) and new (by-reference) from_domain implementations across auction sizes.

Conversion: old vs new

Size Old New Δ
1 order 256 ns 290 ns -13%
10 orders 2.04 µs 1.06 µs +48%
100 orders 14.46 µs 13.40 µs +7%
500 orders 61.5 µs 60.8 µs ~0% (p=0.15, not significant)

The single-order regression is expected: the old impl moves heap fields for free (owned), while the new impl must clone them. The gain at 10+ orders comes from eliminating the pre-call clone that callers previously needed to retain the original.

Arc::clone vs dto.clone (100 orders)

Time
dto.clone() 8.8 µs
Arc::clone 3 ns

~2900x difference. If the hot path is convert-once / share-many, wrapping in Arc is the dominant win here.

Raw output
auction_conversion/10_orders/old   time: [1.9683 µs 2.0366 µs 2.1046 µs]
auction_conversion/10_orders/new   time: [1.0604 µs 1.0645 µs 1.0691 µs]
auction_conversion/100_orders/old  time: [14.157 µs 14.455 µs 14.726 µs]
auction_conversion/100_orders/new  time: [13.345 µs 13.401 µs 13.460 µs]
auction_conversion/500_orders/old  time: [60.395 µs 61.479 µs 62.492 µs]
auction_conversion/500_orders/new  time: [60.565 µs 60.806 µs 61.081 µs]
dto_arc_cloning/.../dto_clone      time: [8.7630 µs 8.8028 µs 8.8508 µs]
dto_arc_cloning/.../arc_clone      time: [3.0260 ns 3.0421 ns 3.0591 ns]
order_conversion/.../old           time: [254.33 ns 256.10 ns 257.96 ns]
order_conversion/.../new           time: [289.55 ns 290.51 ns 291.50 ns]

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.

3 participants