Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support concurrent I/O. #3348

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

usagi32
Copy link
Contributor

@usagi32 usagi32 commented Feb 16, 2025

addresses #1416

@usagi32
Copy link
Contributor Author

usagi32 commented Feb 16, 2025

@ma2bd

Copy link
Contributor

@afck afck left a comment

Choose a reason for hiding this comment

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

I believe the requests are actually sent sequentially currently, so this wouldn't change anything.

If they were sent in parallel, we would need to make sure that all changes to the execution state are made in a deterministic order that doesn't depend on the timing of I/O.

@usagi32
Copy link
Contributor Author

usagi32 commented Feb 19, 2025

Yes, it was also odd to me as I was trying to go along with issue description.

We have two blocking problems for it,

  • The channel is created in the same function (run_user_action_with_runtime) in which handle_request is called. Reference of UnboundReceiver should be passed instead.
  • There will be only one mutable reference of execution state view.

But this will only benefit if multiple runtime instances are simultaneously accessing the same execution state view. But I guess executions of transactions are happening sequentially?

@afck
Copy link
Contributor

afck commented Feb 19, 2025

Yes, exactly, that's also sequential. And even if they weren't, we'd again have to be very careful to keep execution deterministic. 🤔

@usagi32
Copy link
Contributor Author

usagi32 commented Feb 19, 2025

Let me ask this, is sequential execution of transactions required? There is this #2037 means each transaction was proposed keeping in mind the execution state of the last block, and therefore should not modify the state for other transactions in the same block.

I guess we have to be clear about how we want things to behave.

@afck afck requested review from afck and jvff February 19, 2025 15:21
@afck
Copy link
Contributor

afck commented Feb 19, 2025

It's not planned right now to execute transactions in parallel. #2037's motivation is that the "result of the first operation is passed to the second operation", so that, at least, would have to be sequential.

For #1416 we should just try to make reads from the application state parallel where possible. I think I was actually wrong that this approach doesn't address that, sorry! But let's avoid unsafe.

@usagi32
Copy link
Contributor Author

usagi32 commented Feb 19, 2025

But isn't each individual transaction proposed with the regards to the parent block execution state, as transactions likely don't know what outcomes they will produce, and past transactions may also invalidate the future ones. So state should not change
in-between processing successive transactions in a block.

I understand what #2037 is about, but what I meant that its the only specific case that would require the changed state to persist in-between operations, as its currently done.

And isn't that unsafe safe because we know what we are doing? Maybe something with FuturesUnordered or LocalSet can be done, but we don't know the number of futures, its dynamic.

@afck
Copy link
Contributor

afck commented Feb 19, 2025

isn't each individual transaction proposed with the regards to the parent block execution state

No, it's applied to whatever the execution state looks like after the previous transaction. It's the same in most blockchains, isn't it? Anyway, in Linera this is even less of an issue than on other platforms, because it's always a single client proposing the whole block, rather than adding a transaction to a mempool.

And isn't that unsafe safe because we know what we are doing?

(Famous last words. 😅)
Sure, but we'd just like to minimize, in general, the number of unsafe and unwrap and expects etc. wherever possible.

@usagi32
Copy link
Contributor Author

usagi32 commented Feb 19, 2025

isn't each individual transaction proposed with the regards to the parent block execution state

I meant according to the client with non executed block, who doesn't know transaction outcome. Its "supposed" to be like this.

Like bitcoin and ethereum are free to alter index of their transaction and disregard state created by other transactions. Bitcoin keeps the utxo set commutative while ethereum doesn't alter the nonce for the same account. So its fine for them.

I guess this should be fine, if the client uses a proper wallet which will track the state changes by each transaction. It should not be a problem unless the client manually forms the block raw.

I'll try to do something about the unsafe thing. The issue is number of futures is dynamic, so a JoinSet was a perfect candidate which is requires 'static. Both FuturesUnorderd and LocalSet are done with fixed number of futures as you have to manually poll or run them again.
It should be possible though.

Copy link
Contributor

@jvff jvff left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! It's a good attempt, but the blocker right now is the unsafe transmutation to share the &mut ExecutionStateView. Let's see if we can figure out a way around it. There's also some other small tweaks and simplifications that we could do.

while let Some(request) = execution_state_receiver.next().await {
self.handle_request(request).await?;
}
let _ = self.handle_requests(&mut execution_state_receiver).await?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Since the method returns Result<(), _>, we can omit the let _ = here.

Suggested change
let _ = self.handle_requests(&mut execution_state_receiver).await?;
self.handle_requests(&mut execution_state_receiver).await?;

while let Some(request) = execution_state_receiver.next().await {
self.handle_request(request).await?;
}
let _ = self.handle_requests(&mut execution_state_receiver).await?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

Suggested change
let _ = self.handle_requests(&mut execution_state_receiver).await?;
self.handle_requests(&mut execution_state_receiver).await?;

@@ -569,16 +564,12 @@ where
})
.expect("Service runtime thread should only stop when `request_sender` is dropped");

// manual fusing.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could simplify this and remove the loop, if we use std::pin::pin; and use futures::future::{self, Either};:

let request_handler = pin!(self.handle_requests(incoming_execution_requests));

let outcome = match future::select(outcome_receiver, request_handler).await {
    Either::Left((outcome, _)) => outcome,
    Either::Right((result, outcome_receiver)) => {
        result?;
        outcome_receiver.await
    }
};

outcome.map_err(|_| ExecutionError::MissingRuntimeResponse)?

@@ -7,7 +7,7 @@
use std::sync::LazyLock;

use custom_debug_derive::Debug;
use futures::channel::mpsc;
use futures::{channel::mpsc::{self, UnboundedReceiver}, StreamExt};
Copy link
Contributor

Choose a reason for hiding this comment

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

You'll need to fix the formatting here for CI to pass:

Suggested change
use futures::{channel::mpsc::{self, UnboundedReceiver}, StreamExt};
use futures::{
channel::mpsc::{self, UnboundedReceiver},
StreamExt,
};

Comment on lines 376 to 378
let actor = this.read().await;
let response = actor.load_contract(id).await?;
drop(actor);
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a pattern here that we can simplify and remove the drop by doing:

Suggested change
let actor = this.read().await;
let response = actor.load_contract(id).await?;
drop(actor);
let response = this.read().await.load_contract(id).await?;

@@ -89,6 +90,7 @@ where
Ok((code, description))
}

#[allow(dead_code)]
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be best if we could update this method with the contents of the closure in handle_requests instead of keeping the old code.

};

// alternative is to use FuturesUnordered
let self_transmuted: &'static mut ExecutionStateView<C> = unsafe { std::mem::transmute(self) };
Copy link
Contributor

Choose a reason for hiding this comment

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

We try to avoid unsafe as much as possible. Maybe FuturesUnordered (without spawning Tokio tasks) would work. I need to think a bit more about this 🤔

@afck
Copy link
Contributor

afck commented Feb 20, 2025

I meant according to the client with non executed block, who doesn't know transaction outcome.

OK, you're right, it is different than in e.g. Ethereum: There, the client cares about their account, and uses a nonce.

In Linera, the block height of your user chain has a similar function to the nonce; your client tracks your entire chain and proposes whole blocks.

@usagi32 usagi32 force-pushed the Concurrent-execution-state-actor branch from edddeea to f00a39e Compare February 22, 2025 02:30
@usagi32
Copy link
Contributor Author

usagi32 commented Feb 22, 2025

I meant according to the client with non executed block, who doesn't know transaction outcome.

OK, you're right, it is different than in e.g. Ethereum: There, the client cares about their account, and uses a nonce.

In Linera, the block height of your user chain has a similar function to the nonce; your client tracks your entire chain and proposes whole blocks.

Yes, this should not be a problem at least in the single owner chains as long as the client uses proper state tracking and does not proposes a block raw block by hand.

For other types of chains this can be looked upon to achieve some kind of deterministic parallelism. Where some kinds of transactions are required to be serial like in #2037, and others can parallelized.

Let's see.

@usagi32 usagi32 requested a review from jvff February 22, 2025 11:21
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