-
Notifications
You must be signed in to change notification settings - Fork 7
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
Make long running CPU operations async friendly #122
Comments
This article is very relevant and highly recommended reading: In particular it addresses:
|
a reddit discussion today is also very relevant: |
I read the "What is blocking" article. I'll try to add some context that I think is relevant for neptune-core: A regular user that is mining will spawn 2 threads for the mining operation, 1 for the main loop, 1 to 2 for RPC calls (dashboard + CLI), and N for peer threads, where N is the number of connected peers which defaults to 10. This is 15 tokio threads, so on a regular desktop, there will be enough CPUs available (the number that That's a quantitative consideration of the number of tokio threads. How about bottlenecks? I see two computational situations that are specific to neptune-core, and that should lie at the center of our considerations:
Going with the above definition of blocking, it seems to me that the state-updating will always be blocking (as it holds a write-lock over
These two operations, consensus and transaction generation are the core and purpose of any blockchain client, and everything else that's going on must be subject to their whims. I'd happily sacrifice the ability to perform CLI calls and mine while proving takes place if that means that the prover will be faster. I think we can assume that only one proof is being generated at a time, as the transaction creation logic holds a write-lock over None of this is to say that I disagree with any of your above considerations. But going forward, it's crucial to me that we agree on priorities. |
edit: I wrote the below and then was thinking more about the general problem of lengthy atomic updates, and it occurs to me there may be some solutions available that enable lock-free readers or possibly even concurrent writers. I am thinking of something like arc-swap or mvcc. I will explore in a follow-up comment. Thanks for the writeup. Perhaps I can summarize the thrust of your comments as: new-block-updating and proving are slow and require the global write-lock, so everything else will be blocked anyway, and thus we shouldn't waste any time on speeding up things that aren't even executing until these tasks finishe. Is that a fair summary? Anyway I think that is a valid point, worth consideration. Please consider the bulk of this post analysis/consideration of the point. edit: As of now, I'm much more worried about proving than new-block-updating, but that could change if we see it get very slow, as it affects more use-cases. My below comments are mainly about proving, but some apply equally to block-updates. First I want to clear up some terminology and tokio behavior so we can be on the same page talking about these things going forward. You state we spawn 2 mining threads, and so on. In point of fact, we spawn tokio tasks, NOT threads. Tokio tasks execute on tokio's worker threadpool and multiple tasks share each tokio worker thread. Tokio normally has one worker thread per CPU. So on my older laptop with 4 cores, there are only 4 tokio worker threads. All tokio tasks execute on these 4 threads and are scheduled cooperatively. They cooperate each time they call await. Here is a very short summary that explains it better than I can. If we ignore proving for the moment and consider all other operations, then I hope we will agree we want those to be as concurrent as possible and not block eachother any more than necessary for atomicity. You give an example of a "regular user", but I'm not sure who that is. Is it a solo hodler? Is it a miner? Is it a small merchant? Is it a huge merchant? Is it a block explorer? Is it automated trading software? Is it a web-app? Is it an exchange? When I consider bitcoin-core, I alone have used it for several different applications, and usually those were automated. Some were read-only, and never sent any Tx. Typically a web-app has both online and batch activities occurring via RPC calls, and the frequency of online calls will increase with the number of website visitors. So while neptune-core is not a web-server, it is almost certain to be used as a backend oracle/gateway for such. Further, some users/miners are likely to want to be connected to more than the default number of peers, and can do so with a simple config change. Perhaps 100+. So that's a lot more tasks cooperating, all sharing the same 4 (or 8+) tokio worker threads. I provide these as a sketch of the types of use-cases I expect when I think about concurrency. I think it is worthwhile to recognize that some use-cases never involve mining a single block or sending a single transaction. Consider a block-explorer. And others involve sending transactions only very infrequently, eg a Merchant. Such use cases are dominated by tasks that do not call tx.prove(). The other day I performed a count of RPC calls we presently offer, divided up by whether they acquire write-lock, read-lock, or neither. For the record, here they are: read-lock: 18. write-lock: 4. no-lock: 6 Now let's talk about proving. afaik, proving presently occurs in two situations:
Ok, so proving a block-tx is limited to miners, and to a single task and OS thread. And only when a miner actually finds a block, which is a happy event. It is true it will block all tasks that acquire read or write lock for a very long time when it occurs. Perhaps most importantly responding to P2P messages! But beyond that I think we don't need to worry about it too much because miners are dedicating machines for that task and it doesn't affect anyone else. I am more concerned about (1) which affects anyone who sends a transaction. That is absolutely problematic as it is blocking all the read-lock and write-lock RPCs. It is also blocking some peer messages from being responded to. I'm not certain which of them, or how many. That would perhaps be a useful exercise to quantify, as I did for the RPCs. So anything that can bring that proving time down is a win. Full agreement! This issue #122 is partially about adding spawn_blocking() around prove(), so let's consider the case where we do vs if we do not. I will use my 4-core laptop as example. With no spawn-blocking(), one of the 4 tokio threads is blocked while prove is executing. This leaves 3 remaining tokio threads to process tasks. Let's say the The case where we do call spawn_blocking is similar. In this case the prove() call executes on tokio's blocking threadpool and all 4 worker threads remain available for processing tasks. So the only difference is that we have 4 threads available for processing any tasks that no not require read-lock or write-lock. It's a win, but perhaps not a meaningful one. Which I believe is your point. ;-) For completeness, let's say that one day we decide to run with tokio's single-thread executor instead. Perhaps just for giggles, I dunno. In this case, the version that calls spawn_blocking() would still be able to process a Okay, so I acknowledge your point that Proving is an important gating issue/problem, and that other efforts to improve concurrency in the async realm may not bring much relative benefit, at present. Even so, I think that #133 should be merged, for these reasons:
So those are my thoughts. I'm not too hung up on #133 -- if it doesn't get approved for merge, so be it. 😄 |
Ok, let's brainstorm a bit. regarding tx-proving inside related: Pls remind me why creating a Tx needs to modify the global state at all. In bitcoin et al, that's not the case and Tx can be generated entirely offline, and then broadcast by anyone. If we could do that then tx-proving could operate on its own OS thread and not block anybody. If we think about the general problem of having shared state that takes a long time to update, one starts to consider working on multiple versions of the state. If the writer can be writing to a new state version while the readers see the old version, then the readers do not need to lock at all. In rust, the arc-swap crate provides this functionality. Going further, many databases, such as postgresql allow multiple writers to write concurrently, each on their own version and their own view of the state, using multi-version-concurrency-control (mvcc). mvcc libraries and database/bindings are available for rust. So in theory, it seems like it should be possible to solve this problem in an elegant fashion. A practical difficulty lies in the way however because our GlobalState represents the MutatorSet db, wallet db, and the blockchain-files. So for this to work well, I believe we would need to use a persistent datastore that supports mvcc. That would of course be a big change. However, sometimes practical realities dictate change is necessary, and this could be one of those times. That's it for now. I'm not intending an exhaustive writeup here. Just thinking outside the box a little. |
You're right that we might not need a write lock during proving. My thoughts were that the wallet should mark the UTXOs used as input to the transaction somehow, such that if a later transaction was made, before the 1st one is mined, the UTXOs would not be used twice. Note that this marking of spent-but-not-yet-mined UTXOs is not being done yet. This marking would change the global state, but it doesn't have to be done with any atomicity, so I don't think a write lock is needed during proving, even after we add this feature of preventing the reuse of UTXOs. You should be aware though, that the validity of a transaction is always proved in relation to a specific block. The accumulator scheme (the mutator set) changes for each block, and it is what allows us to prove membership of the UTXO set (all unspent, historical transaction outputs) without having to know all historical transactions. |
That's a part of it. A perhaps bigger problem is our novelty budget12. The novelty of the Neptune blockchain comes from its cryptography (new hash function, new accumulator scheme, new STARK engine with associated VM, and tons of assembler for this VM). The people working with this part of the stack need to be allowed to dedicate all their brain cycles there, so the rest of the codebase should be kept as simple as possible. Footnotes
|
Cool, so I did a little experiment and made create_transaction() take
I commented this code out, and everything builds just fine with This code is modifying wallet_state, so it truly does need Is it really necessary to add this UTXO at time of Tx creation? Inside Alternatively, we could move the write-lock acquisition deep inside where this mutation actually occurs, and it would not longer be wrapping the call to prove(). That seems a lot uglier to me though.
To a specific block or to the previous block? In other words, let's say we start creating a transaction when block with height 56 is the tip, and by the time we are done with proving and creating the Tx based on 56, now block 57 is the tip. In this case, will our Tx still be valid, or we need to recreate it based on block 57? If the Tx based on 56 will still validate, then it seems like the change I propose above should be fine. And logically I think that must be the case, else it is already possible for the Tx to be invalid for other nodes, as there are potential time delays. But I'm a little out of my depth here, so will toss it back to you for your thoughts @Sword-Smith. |
I don't really see a problem with that, other than that the client could be shut down/stop running between the broadcast of a transaction and when an expected incoming transaction is added. So I would suggest flipping the order: Let's store the expected incoming UTXO before the transaction is broadcast, but the write lock can still be held much shorter than it is now. Would that work? Maybe that's what you're referring to here?
Yeah, we might have to do something like that in order to get the ordering correct and prevent the potential loss of the change UTXO.
The transaction would either need to be recreated, or its proof would have to be updated. The logic for the latter logic, we havent' written yet. |
Addresses Neptune-Crypto#122 Enables caller of create_transaction() to choose whether the Change UTXO will use OnChain or OffChain notification. Note that caller could already do this since the last commit by manually creating the Change UTXO so that sum(inputs) == sum(outputs). Therefore, this change only makes it a bit easier, and is more consistent in that the caller can easily control the notification behavior of all outputs. The default behavior is still OffChain notification and doc-comments and example usage recommend this for normal usage. Also, this fine level of control is not available in the RPC send and send_to_many endpoints. Only in the rust API. Changes: * add change_notify_method arg to create_transaction() * update tests to use extra arg. * rename UtxoReceiverData --> UtxoReceiver * rename TransactionData --> TransactionDetails * fix: backwards logic in UtxoRecipient::auto() * improve doc-comment for create_transaction() * add ChangeNotifyMethod enum * add some missing doc-comments
We should not let CPU intensive functions block the tokio executor for long as this harms concurrency, which can potentially stall other tasks: p2p connections, rpc calls, mining, etc. [1]
The task here will be to identify non-async functions in neptune-core that perform lengthy operations, and then wrap them with tokio's spawn_blocking() or possibly block_in_place().
"lengthy" is of course a vague term. As a starting point I would consider 10 ms lengthy, 100 ms very lengthy, and 1+ seconds as falling over dead. [2]
Known candidates:
Add others above as we identify them.
note: At this time, I'm really most interested in dealing with the very worst offenders, such as proving, which I'm told may take minute(s). I think others are low priority and can be dealt with as we notice them through testing. So I'm not proposing with this issue that we spend time now with profiling and optimization.
[1] as an alternative, or in addition, we might consider creating an OS thread for the rpc server, mining, and possibly each p2p connection, which are presently all tokio tasks in tokio's threadpool. async/await uses cooperative multi-tasking, while OS threads use preemptive multi-tasking.
[2] places where triton-vm scripts are run:
[3] I'm not sure there is any clear definition of what constitutes "blocking". This quote seems insightful:
I found an interesting discussion about blocking async code here written in response to this. Apparently async_std in 2019 had a prototype executor able to detect lengthy blocking code and start a new thread for it automatically (but it was never merged). And there are various comments about what constitutes blocking code or not.
The text was updated successfully, but these errors were encountered: