Skip to content
This repository has been archived by the owner on Dec 26, 2023. It is now read-only.

SVM Raw Transaction Ranges #69

Closed
YaronWittenstein opened this issue Nov 17, 2021 · 19 comments
Closed

SVM Raw Transaction Ranges #69

YaronWittenstein opened this issue Nov 17, 2021 · 19 comments
Assignees
Labels
svm SMIPs related to SVM

Comments

@YaronWittenstein
Copy link

YaronWittenstein commented Nov 17, 2021

SVM Raw Transaction Ranges

Overview

This SMIP proposes a mechanism for exposing the bytes of the raw transaction while staying compliant with the Fixed-Gas Wasm restrictions.

Goals and Motivation

This SMIP is a prerequisite for applying crypto-related computations on parts of a transaction.
For example, to implement a non-trivial verify function, the Template needs access to the raw transaction data.
Aside from that, other functions should be able to run similar computations.

High-level design

Reconstruct Transaction Raw Data

The way things are designed today is that the Full-Node (go-spacemesh) is in charge of peeling off the Envelope part of a raw transaction (a.k.a, the one sent over the network) decoding it.
The Full-Node must be able to reason about the Envelope, and it can't treat it as an opaque piece of data.

Later, the go-spacemesh (written in Golang) communicates (using the go-svm library) against SVM (written in Rust).
The same Envelope is passed in a binary format between go-svm and SVM due to FFI limitations.

The format used for the Envelope in the FFI boundary isn't the same as the on-the-wire one.
However, since we want SVM to reconstruct the original Envelope, it's advised that the two encodings will converge.
Otherwise, it'll require more coding effort, which seems excessive.

If the Envelope encodings unite into a single one, the interface between go-svm and SVM can remain today.
Right now, the Envelope and Message are passed as two different parameters; it's an implementation detail whether to stay with that explicit separation.

In any case, SVM needs to know how to transform the encoded Envelope passed to it to the one sent over the network.
(If the two Envelope encodings used will be the same, we can view the transformation as the Identity function).

Once SVM will hold both raw pieces, namely Envelope and Message. It could concatenate both and have the original raw transaction.

Host Functions Exposing Raw Transaction Ranges

The Runtime component should be extended with multiple host functions that will expose the bytes range of the raw transaction data.

Each host function will return a tuple representing a memory range.
This list might not be exhaustive, but it should be very close to the final one:

  • svm_tx_range() -> (i32, i32)
    Should return a tuple containing the memory range holding the transaction.

  • svm_tx_principal_range() -> (i32, i32)
    Should return a tuple containing the memory range holding the transaction's Principal address.
    We probably can omit the tuple second part since we know that an Address is exactly 20 bytes long.
    However, it might be better to stay consistent with the convention used for the other similar methods.
    (and be compatible if additional addresses length will be supported someday).

  • svm_tx_calldata_range() -> (i32, i32)
    Should return a tuple containing the memory range holding the calldata.

  • svm_tx_verifydata_range() -> (i32, i32)
    Should return a tuple containing the memory range holding the verifydata.

  • svm_tx_func_range() -> (i32, i32)
    Should return a tuple containing the memory range holding the function name of the transaction.

Even though Wasm has support for Multiple Return-Values, the Rust support might not be production-ready yet.

In that case, each host function described here will have to be split into two separate functions:
One is returning the starting memory address and the other the ending one. So, for example, instead of having svm_tx_range we'll have both svm_tx_offset and svm_tx_len.

See also the existing svm_calldata_offset and svm_calldata_len as a good reference.

To have the raw transaction in memory, the Runtime will have to copy it into the Wasm instance memory in a very similar manner as done today to the verifydata/calldata.

The call for svm_alloc will take the transaction raw data size into account, and the Function Environment managed by "the host" will keep the ranges of the transaction pieces. It means that the Function Environment will hold pointers to different parts of the raw transaction.

One way to compute these pointers (a.ka offsets) will be by extending the behavior of the Codec not only to return a decoded transaction but also to return metadata containing these offsets.

From there, deriving the memory pointers should be easy.
For example, if we know that the raw transaction starts at memory address 1000 and that the function name field is at offset 50, we can infer that the function name in memory begins at memory address 1000 + 50.

Dependencies and interactions

  • go-svm
  • SVM FFI
  • SVM Runtime
  • SVM Codec

Stakeholders and reviewers

@noamnelke
@lrettig
@neysofu
@avive
@moshababo

@YaronWittenstein YaronWittenstein self-assigned this Nov 17, 2021
@YaronWittenstein YaronWittenstein added the svm SMIPs related to SVM label Nov 24, 2021
This was referenced Dec 1, 2021
@noamnelke
Copy link
Member

Do you mean to expose actual memory addresses to the contract or am I misunderstanding? Why not make addresses relative to the start of the tx_data and thereby hide the actual memory address? Exposing memory addresses to unsafe code sounds wrong to me.

@YaronWittenstein
Copy link
Author

@noamnelke, this SMIP describes host functions for SVM.

For example, we'll require crypto-related functionalities such as SHA3.
Let's say we it'd be called svm_sha3 function - it'd make sense that its signature would expect a memory address pointing to the start of the blob to run the hash function on (and of course, the blob size in bytes as a second parameter)

The svm_sha3 has no idea what's a transaction. It just expects a blob laid out in memory.

@noamnelke
Copy link
Member

One way we may be able to work around this issue is to create an opaque Pointer or Range class that's returned from these functions. The wasm code can store instances of this type and pass them along to the hash function, for example, but not use them in unintended ways (e.g. print them in logs or use them for randomness).

@YaronWittenstein
Copy link
Author

Wasm was designed from the start to be as deterministic as possible.

As this link describes:

WebAssembly is a portable sandboxed platform with limited, local, nondeterminism.
Limited: nondeterministic execution can only occur in a small number of well-defined cases (described below) and, in those >cases, the implementation may select from a limited set of possible behaviors.
Local: when nondeterministic execution occurs, the effect is local, there is no "spooky action at a distance".

We're supposed to be good since we don't use any of these Wasm features with nondeterminism potential.
Even if we had written Rust code with some randomness (say some Memory Allocator or random numbers) - the resulting Wasm code would have required the assistance of certain host functions targeting the operating system itself.

We don't use any WASI host functions implementations or system-calls polyfills.
It means that the only place we could, in practice, expose nondeterministic behavior is by our written host functions.

My only recommendation is that the SVM code will undergo a security audit.
I don't expect such an audit to find any non-deterministic issues, but it's always good to have another pair of eyes.

And of course, an audit could find other security issues (or maybe things related to potential resources exhaustion such as Stack exhaustion, for example).

@noamnelke @lrettig @neysofu @moshababo @avive

@noamnelke
Copy link
Member

I understand what you're saying, but I still feel uneasy about exposing the allocated memory address to contract code.

I understand that it's relative to the sandbox and deterministic, but it still feels like a quirk. What do I mean by quirk? It's an implementation detail of our memory allocator. If we ever decide to change the implementation we must implement backwards compatibility, because past contracts may have done stupid things with the allocated address that affected the results of running the contract code. This makes our memory allocator part of the protocol.

I think we should keep the protocol minimal and there's no benefit in making the memory allocator part of the protocol if we can avoid it. I believe my solution (wrapping memory addresses in an opaque object that can be be interacted with in specific ways) allows us to avoid it. A nice side benefit is that it allows us to migrate to a non-deterministic memory allocator in the future in a backwards compatible way (without affecting contract execution outcomes).

@YaronWittenstein
Copy link
Author

YaronWittenstein commented Dec 12, 2021

A Memory Allocator is essentially a function that looks for free space of a given size in Memory and returns it.

Each contract can use a different Memory Allocation algorithm internally.
Even more - each function in any Wasm contract can implement (in theory) a different Memory Allocation inside.

We use the Rust Memory Allocation interface since we code our contracts in Rust. It's just an implementation detail and nothing related to the consensus.

The EVM bytecode isn't different. It has a couple of opcodes targeting Memory, such as MLOAD, MSTORE, MSTORE8
I don't see any possible bytecode managing without supporting any Memory related opcodes.

@tal-m
Copy link

tal-m commented Dec 14, 2021

@YaronWittenstein: I have to agree with @noamnelke here. Smart contracts shouldn't need a memory allocator at all -- definitely not for our initial version that doesn't have dynamic memory allocation. When we do add support for dynamic allocation, I agree that we should use opaque handles like Noam suggested. At the WASM level, these can be sequential integers (like file handles in UNIX), so there's no "leakage" of implementation details such as memory allocation strategies.

Regarding the tx data, why not just map it to the beginning of memory (i.e., starting at 0)? This way we don't have to allocate anything, and the user code can easily access it without any special host calls...

Finally, for the hashing API, we should support the standard init/update/finalize interface (e.g., see here ) which lets you incrementally add data to be hashed (rather than having to pass the entire buffer in one step). This makes it much easier to hash more complex messages (or programmatically generated ones) without having to allocate or copy memory.

@YaronWittenstein
Copy link
Author

YaronWittenstein commented Dec 14, 2021

As long as Smart Contracts will use Memory, we'll have support for Memory Allocation. So if we want to have shared data between functions (or Wasm blocks), we need to use Wasm Memory.

Using the Wasm Stack isn't relevant. It can just push and pop out of the top of the Stack. So it's not like rsp register that can point to any random place such as rsp + 100 (and cause security issues).

And even if we could hack the Stack, we shouldn't. The Wasm Stack was meant for expressions computations and not for holding data.

Coming up with a replacement filesystem-like API to create files and get back handles will give, I believe, nothing.
Actually, I think it will make things worst.
It'd result in re-implementing a subset of the Wasm Memory using our own ad-hoc API.

And what'll be the gain? Enforcing the order of allocated file handles numbers?

The current Memory allocation algorithm used (internally) is the most naive I could imagine: just incrementing a counter. There is no leakage of implementation and nothing consensus-related.

Memory allocation doesn't necessarily mean that you need dynamic allocation. Even when you know upfront how much Memory you'll need, you still need to write and read data from it.

You can't decide that the Memory Counter will start at length zero (or another hardcoded offset). Rust tiny minimal runtime will pick another address for you. Further, it's a bad design to assume anything about the initial value of this Counter.

Even if I could enforce the emitted Wasm code to start laying data on Memory offset zero/other constant - I'd never done that since it could easily backfire with a future compiler version. That same design decision holds for any programming language that would compile to Wasm.

The only thing that truly matters is whether the code is deterministic or not. And being deterministic is a must, of course, for any Contract code.

I don't understand how you want to implement an Opaque pointer to Memory. In the end, after peeling off the abstractions layers, you'll end up using the same Wasm Memory opcodes.

I have no problem with having an additional Hash Builder API if required for the 1st Mainnet. Otherwise, I'd wait to the future when it is relevant.

Starting with a simple Hash Once API should suffice, be easier to implement, and be more performant (no need to allocate any Hash-handles internally).

@tal-m
Copy link

tal-m commented Dec 14, 2021

Let's separate the low-level WASM runtime from the higher-level rust code.

Wasm

The most consensus-critical part is the WASM definition. This is the part that must be part of every node's code, and must be executed identically by all nodes.

Wasm already defines memory the way I would like us to -- a memory is a contiguous, linear array of bytes, with indices always starting at 0. You can only access the memory via explicit load/store opcodes, or via external (host) functions.

All references are opaque, and kept in tables -- so you can ask to call the i'th function in the function table (i.e., the "function handle"), but you can't access any bit representation of the function's address in memory (or its code, of course). Memories are handled in the same way -- there's a table of memory references, and you can load/store by specifying the index of the memory in the table, and the offset into memory (as I understand it, at the moment only a single memory is allowed, but the generic structure is as I described, and the limitation may be lifted in the future).

So there's no need to reimplement wasm memory handling --- it does exactly what we want.

There's already a wasm-friendly way to load constants into memory, by using "data segments" (there are special opcodes to initialize memory with a data segment) --- to make use of this, for example, we could just define the transaction data as a data segment that loads at memory address 0.

Dynamic data structures

For future dynamic data structure support, we can just use Wasm's native reference types, avoiding memory allocation altogether at the wasm level. (e.g., wasm code would get a reference to a key-data storage structure, and host functions for reading and storing data)

Rust

As for the rust part, I guess I don't understand why it needs memory allocation if there's no dynamic memory. If the rust compiler requires dynamic allocation (and thus an allocator) for even the extremely simple contracts we're starting out with, maybe rust isn't the best high-level language for writing the contracts.

@neysofu
Copy link

neysofu commented Dec 15, 2021

Rust is a good choice precisely for the fact that it gives us near zero-overhead control over the final Wasm code. Our proposed solution to write transaction data to a memory section that is allocated on the fly is orthogonal; that's where the need for a memory allocator comes from, not Rust itself.

NEAR, Substrate and other projects come to mind when talking about Rust usage for smart contracts.

Both NEAR, Solana, and other established projects include a memory allocator by default when writing smart contracts.

@tal-m data segments only solve this problem partially: you still need to choose an unoccupied memory address. Chances are our users are not writing Wasm code by hand, so you're just running the risk of choosing a memory location that the compiler might decide to use for literals, other constants, etc.. Which is obviously very bad. We'd be playing with fire here.

A memory allocator is just a no-brainer. It's a familiar concept that smart contract authors are familiar with: just allocate some memory, write tx data to it with a host call, and there you have it. This pattern is flexible and works for basically everything we need.

This works and it's the most common approach by other projects as well, so I'm inclined to do this.

Circling back to the hashing APIs - I agree that streaming-based hashing APIs are generally preferable. Of course, we don't have a hard requirement for this at the moment so we can go with whatever is the easiest to implement for now. This most likely means simple one-shot hashing, without init / update / finalize.

@tal-m
Copy link

tal-m commented Dec 15, 2021

A smart contract VM should be treated more like an embedded system than a full-scale computer. It has very constrained resources, some memory is special-purpose and reserved, etc. We definitely don't want to encourage smart contracts to contain big chunks of code just because it's a little easier for our high-level toolchain (from your link above, the "small" allocator is about 1k, which is huge for a contract).

If we have full control over the final wasm code, as you say, we should be able to define symbols at specific locations in memory. There seems to be a way to do this in Rust: https://docs.rust-embedded.org/embedonomicon/memory-layout.html

I think what it boils down to is defining the tx_data as an external symbol, and controlling the linker so this symbol is always set to the correct location in memory.

@neysofu
Copy link

neysofu commented Dec 15, 2021

Most (all?) users will find that a bump allocator is enough for their needs, so we don't have to worry about size. We don't even ship wee_alloc.

Asking end-users to fiddle with linker scripts is a no-go IMHO (it wouldn't be transparent to them), especially when we have a better alternative.

@tal-m
Copy link

tal-m commented Dec 15, 2021

I agree that users shouldn't fiddle with linker scripts. But since we will provide the toolchain, they won't need to --- the toolchain would include any necessary linker scripts.

The point is that we should treat every byte of contract code as a "precious resource". Similarly, every bit of complexity at the wasm level has cost as well. If we can spend a little more time on optimizing our toolchain to ensure that common contract code is a few bytes smaller or more straightforward, it's worth it.

Even a bump allocator adds complexity and size. If we need it in every single contract, that's a big deal (if we need it even to run verify, it's a very big deal). For very common, complex operations that are necessary at the wasm level, we will provide a "precompiled" host function. However, if something isn't necessary at the wasm level, we don't want to pay the overhead for it just for a little extra convenience in our Rust toolchain (note that by "convenience", I mean for us, the developers of the toolchain, not for smart-contract developers).

@neysofu
Copy link

neysofu commented Dec 15, 2021

But since we will provide the toolchain, they won't need to --- the toolchain would include any necessary linker scripts.

Alright, but we're merely providing the SDK in the form of Rust libraries. We already bestow upon the user a non-ideal but acceptable amount of package configurations (e.g. we ask them to explicitly optimize for size, etc.) because we can't supply those settings by ourselves. So it's not trivial to make this work nicely from a UX perspective.

I can see your point, really, but a bump allocator is a few bytes in size. Literally... that's not something we should optimize for before even releasing mainnet IMHO. Developer time is really precious right now and what we have (1) works and (2) is not consensus-threatening. This is just my opinion, so if it's up to me great, otherwise I'll just do whatever the team decides. No biggie.

@YaronWittenstein
Copy link
Author

YaronWittenstein commented Dec 15, 2021

Wasm Tables are used to hold reference types (opaque values), but their usages have nothing to do with the Wasm Memory.

Today, the Wasm Table supports only a single reference type called funcref. It's used for implementing polymorphism. (i.e dynamic dispatch at run-time)

Function references are the Wasm way for securely implementing pointers to functions. Wasm has a dedicated opcode for that - the call_indirect . It expects an index pointing to the Table entry holding the function reference.

We don't use the call_indirect opcode since we need to know the called function at compile-time due to the Wasm Fixed-Gas applied. And even if we did use it, it has nothing to do with Memory Management.

Now, there is a Wasm proposal named Reference Types for WebAssembly

This proposal allows references exchanges between the Wasm Instance and the Host. For the Wasm Instance, these references are opaque.

The classic example for using refs is passing a DOM Node from the Browser (the Host) to the Wasm Instance. It will, in return, keep it for a while and send it back later to the Browser (the Host).

Another good usage is allocating Operating-System resources such as file handles or TCP sockets.

Say we have a Wasm code that wants to open a new file, it could call the Host (like open("my-file")) and get back an opaque value that only the Host will know what it means.

This article has an excellent example for using references and files: WebAssembly Reference Types in Wasmtime

Another motivation is that, in the future, Wasm Instance will be compiled by linking a couple of different Wasm Modules. These modules could exchange references between them. (see: Module Linking)

Besides, the references proposal is a prerequisite for more advanced future additions to Wasm, such as Exceptions handling.

The bottom line is that the Wasm references will be used for interoperability and future advanced mechanisms.

None of the above is related to Memory Management.
If we want to read or write into/from a running Instance's Memory, we need to use explicit Memory Addresses (a.k.a, pointer, or offsets).

As @neysofu said (and he's right) - Data Segments won't help. First, we don't use any constants at all. And also, these constants sit in the Instance' Memory in the first place.

The only theoretical solution that I can think of is re-implementing the Wasm Memory Mechanism in the Host and exposing them as host functions to the Wasm code.

Will it work? I'd say it'd in the same way that we could delegate each arithmetic operation to the Host...

Now, regarding the Memory Allocation. First, if we want to get into low-level stuff, we need Memory Allocation since we receive data from the Host. The calldata is a Blob of bytes.

We don't know ahead of time its size. We can have some upper limits and use static Memory in Rust which will compile to Wasm code containing a Memory hardcoded Address (in the same way as in the Data Segments).

Is that an elegant solution? I don't think it's. Feels more like a hack.
First, again, it will contain a hardcoded Memory Address given by the compiler (we don't control that Address),
And second, we'd still end up reading or writing to Memory.

Our current solution is superior because we make zero assumptions.
We let the Rust allocate data for us. The cost is incrementing an integer. So each time we need more Memory, we just increment an integer. It can't get any simpler and elegant, IMHO.

Further, if we'll have a dedicated buffer in Memory that starts at some memory location - we'd still need to keep a pointer to the last allocated memory address - it's the same counter solution we've today.

For last, in terms of CPU cycles, both the "Allocation using Counter" and the "Static buffer with the accompanied Counter" will ask for Memory from the Operating-System exactly once - upon the Wasm Instance Initialization.

For the Multiple Wasm Memories - that's true that someday Wasm will support that. I don't see it as a big deal.
I'd bet that any LLVM language will continue using only Memory #0 for the foreseeable future.

Also, multiple Memories is equivalent to one in the same way a Turing machine with one tape is as capable as another Turing machine with constant k-tapes - i.e nothing special.

For using Rust - let's say that Rust had a small disadvantage indeed? (it has no problems with Memory Management as described above) - does it mean that we need to discard it for writing Contracts? is that a good enough reason for saying, "let's not use Rust"?

There is a good reason that almost any Crypto company using Wasm uses Rust for writing Contracts instead of any other language. Writing Wasm manually isn't a reasonable solution.

In terms of disk storage, it's negligible. A Template size will be like one or two dozens of transactions probably. So I don't see any point in saving a couple of bytes and using hacks.

Having solid foundations, such as the SDK, for writing Contracts properly, elegantly, and without hacks is x1000 more important IMHO. Otherwise, let's write the manual Wasm code...

I'd not only encourage to continue using Rust for writing Smart-Contracts. I hope that if the company creates any high-level Smart-Contracts programming-language, it'll write a compiler that will transpile into a lower-level Rust code.

To end this comment I'd like to reiterate my previous statement:
The only thing that truly matters is whether the code is deterministic or not. And being deterministic is a must, of course, for any Contract code.

@tal-m
Copy link

tal-m commented Dec 16, 2021

Being deterministic is indeed critical, but it isn't the only thing that matters. Efficiency matters very much as well.

As I said before, we need to separate out our consensus-level wasm specification (the "core" SVM), and the high-level Rust SDK that can target the core SVM. The former is something we must finalize before genesis (at least to the level of support needed for our initial smart contracts), and is painful to change. The latter is something we don't need to commit to until we support user-written smart contracts, and even then it's much easier to change.

The core SVM should be written in such a way that it promotes efficient smart contracts. Unlike a local computation, where we don't care about a few bytes here or there, or a few extra cycles, every contract written to the blockmesh is duplicated potentially millions of times (by a very conservative estimate). Transaction executions incur even more duplication --- and with account unification, the verify part of transactions can be executed additionally millions of times with no payment. So it has to be extremely efficient, or it can be an avenue for a DoS attack.

Also, note that unlike old transactions, templates must be kept in the global state --- their storage cost is much higher than the cost of already-processed transactions.

On the other hand, I agree with you that we don't want to use "hacks". We want to have a good, solid design. I also agree that we want users to write contracts in a high-level language, and I'm not against using Rust as one such high-level language, unless it's impossible to achieve our efficiency goals in Rust, which sounds unlikely.

However, we should design the VM with efficiency as the first priority (deterministic execution is not an priority, it's a non-negotiable requirement).

One of the things I'd like to be able to optimize away is memory copy operations for large chunks of data (such as transaction calldata). We don't necessarily need to optimize our implementation for genesis, but at the very least, the VM design should allow such optimizations in the future. Unfortunately, zero-copy memory handling appears to be a known issue in webassembly at the moment. I was thinking that this could potentially be solved via the multi-memory mechanism (i.e., transaction data would be a separate memory "owned" by the host, and no copies would be needed), but this isn't supported by wasm yet. This is also why I was looking at data segments (since we need read-only access to the transaction data, this would suffice) but it looks like wasm doesn't allow access to data segments except via copy into memory.

Another avenue to optimize memory access is via the streaming hash API: since hash_update is a host function, it can access transaction data natively so, for example, the verify method could compute the hash of the transaction data without copying it into wasm memory first, if we provide something like the following host function:

  • hash_update_txdata(hashref, offset, len) which updates the hash object referenced by hashref with len bytes of txdata starting at offset.

(a non-streaming hash might work as well for our initial version, if it turns out we only ever sign contiguous ranges from the transaction data). For account addresses/public keys and signatures, we could define host-functions such as:

  • get_txdata_pk_ref(offset) that would return a reference to a public key that appears in the tx data at offset offset and
  • get_memory_pk_ref(offet) that would return a reference to a public key that appears in the wasm memory (we probably don't need this for our initial contracts).

We'd have equivalent functions for getting references to signatures.

For verification, we'd have a host function such as

  • verify_signature(pk_ref, data_hash, sig_ref) that would verify a signature sig_ref with respect to public key pk_ref on the data data_hash.

Finally, we can have additional host functions that access raw tx data. For example:

  • get_txdata_buffer(offset, len, dst_offset) would copy len bytes starting at offset in the txdata to the wasm memory at dst_offset.

With these functions (or something similar), I think we can support zero-copy operation over tx data for verify, and probably for all of our initial contracts. If we only use get_txdata_buffer with constant-length buffers (which is what I think we'll need for our initial contracts), I don't see why we would need a Rust allocator at all (of course, I may be missing something --- I haven't delved very deeply into the Rust<->wasm interface --- so please correct me if I am).

For more complex contracts, which we will want to support eventually, we probably will need to figure out how to handle variable-length buffers more elegantly. However, we have more time to work on the "correct" API for these advanced uses.

If writing our initial contracts efficiently in Rust requires too much dev overhead in terms of toolchain wrangling, I think that even using something like C would be ok, as long as we carefully audit both the C source and wasm output (which we'd want to do for Rust as well). We're not actually using most of the safety properties of Rust here, since there's no memory handling, and moreover I think these contracts are going to be extremely simple; ideally to the point where it's feasible to write them in wasm directly (the complex parts of the contract, such as signature verification, are implemented as host functions).

@YaronWittenstein
Copy link
Author

YaronWittenstein commented Dec 16, 2021

Unlike a local computation, where we don't care about a few bytes here or there, or a few extra cycles, every contract >written to the blockmesh is duplicated potentially millions of times (by a very conservative estimate)

That's not true. The whole point of having a Template is that it will be stored only once on a disk. So if we manage to reduce, say, 10 bytes out of a Template, we'll end up saving 10 bytes regardless of the number of Accounts spawned out of it in the future. That's just one of the advantages of SVM over other platforms such as EVM.

Also, note that templates must be kept in the global state, unlike old transactions --- their storage cost is much higher than >the cost of already-processed transactions.

Of course. @neysofu took care of it already. I believe it should be a joint effort with the Research to do the final pricing of a Transaction.

Our toolchain is very simple. Just run a tiny script that compiles the code and then crafts a Deploy Template Transaction. (the Message part without the Envelope part). It's both simple and for internal use only since we don't let anyone send a Deploy Template Transaction on Mainnet. We'll only support the Genesis Templates.

We use many features of Rust. First, we have Types (Rust Type System is superior to C, to say the least). The Rust SDK is built around Procedural-Macros - this is considered an advanced feature of Rust.

We have feature flags used as well. For example, each written Template could be compiled to Wasm or a Mock Mode (for Unit-Testing).

Apart from that, writing the same code in C would be not only less secure (well, it's still C) - it'd result in many more lines of code. On the other hand, Rust is more higher-level and expressive (and safe, of course).

We use Memory-handling; it's just that our implementation of it is the simplest one possible (as explained in a previous comment). We can swap it with a different one, and we even have feature flags such that each compilation could pick a different Memory Allocator.

We already do that. When we compile a Template to a Mock Mode (for testing), we use a different Memory Allocator (the wee_alloc one).

Regarding writing a Template in Wasm by hand. I don't think anyone is capable of doing it.

Here is an example for a super-super small Template:
https://github.com/spacemeshos/svm/blob/6edb73de199fafce0953f82af061ca1090ff911c/crates/runtime/tests/wasm/calldata/src/lib.rs

Here is the compilation script to Wasm:
https://github.com/spacemeshos/svm/blob/6edb73de199fafce0953f82af061ca1090ff911c/crates/runtime/tests/wasm/calldata/build.sh

The resulting code (in Wasm Textual Format) is 3335 lines.
The binary Wasm I got (after optimizing it) is 7KB.
Our actual Template won't be significantly greater. And again, it's only a one-time disk cost per-Template.

Regarding the Crypto-related host functions. We can optimize it just for the Transaction.

However, this solution has a trade-off that should be clear.
The current proposed solution (not implemented yet) assumes that the Transaction data is laid out in the Instance's Wasm Memory.

The advantage is that we can have general-purpose host functions that will have computations against any pieces of data laid in Memory. It means we can Hash a piece of the Transaction or any other variable using the same host function.

The other way is to have each Crypto host function with a dedicated Transaction version.
So instead of having hash(offset, len), we'll also have hash_txdata(offset, len) etc.

Copying the Transaction into the Memory will result in a few CPU cycles. However, IMHO it's super negligible.

That said, I don't have any issue with optimizing the Crypto host functions since we'll use the Transaction as the input in the vast majority of cases.

However, we'll still require to have the sigdata copied into the instance Memory.
(See the SVM Transactions Signatures SMIP).

We'll require that for the same reason as calldata is being copied to the Instance Memory. Again, we want to treat these buffers as a Blob of bytes that can have any arbitrary ABI used. SVM should have no idea what's inside.

Probably most Templates will concatenate Signatures under the sigdata. But, as said, I don't want us to enforce that at the infrastructure level of SVM. It would be less than optimal. Better to let each Template decide how to interpret this piece of data.

Since copying internally the sigdata is the way, I think, to go, I'd copy the whole Transaction and keep things more straightforward.

Of course, we can always add future host functions to optimize and avoid copying the Transaction into the Instance Memory (new Templates will use it).

But assuming that we'd like to support the general-purpose Crypto host functions - I'd start only with these and move on.

@tal-m
Copy link

tal-m commented Dec 17, 2021

@YaronWittenstein: There's something I don't understand about your proposal --- from what I can tell, the svm_tx_range() host function needs to allocate the storage itself. So the allocator needs to live on the host side. How does this allocator know not to conflict with heap memory used in the contract code? Is the allocator itself exposed via host functions?

Focusing for a moment just on this: my proposal for copying data to instance memory (get_txdata_buffer(offset, len, dst_offset)) doesn't require the host to have a privileged heap allocator, while still allowing wasm contract code to handle its own allocation if necessary.

However, we'll still require to have the sigdata copied into the instance Memory.
(See the SVM Transactions Signatures SMIP).

I don't think we need to copy sigdata into instance memory. My proposal was to define a host function that returns a reference to the signature data held by the host. Thinking about it some more, I think we can do something even more general -- define generic host functions get_txdata_buffer_reference(offset,len) and get_mem_buffer_reference(offset,len) which would return a reference to the given memory (without making a copy). Host functions can then accept these references rather than memory offsets, so we don't need to define multiple different host functions for combinations of memory and txdata. (Alternatively, the host functions can accept a (memarea,offset) tuple instead of just a memory offset, where memarea specifies whether the offset is relative to txdata or to wasm memory).

We'll require that for the same reason as calldata is being copied to the Instance Memory. Again, we want to treat these buffers as a Blob of bytes that can have any arbitrary ABI used. SVM should have no idea what's inside.

In many cases we won't require calldata to be copied. For example, during the standard verify it's only necessary to hash and check signatures. But with my proposed API, a template can still copy specific parts (or all) of the template data to main memory if it needs to process it in wasm (e.g., for a multisig verify, it might want to read the indices of the public keys the signatures should be verified against).

That's not true. The whole point of having a Template is that it will be stored only once on a disk. So if we manage to reduce, say, 10 bytes out of a Template, we'll end up saving 10 bytes regardless of the number of Accounts spawned out of it in the future. That's just one of the advantages of SVM over other platforms such as EVM.

The duplication I was talking about is the number of nodes who each have a copy.

@YaronWittenstein
Copy link
Author

YaronWittenstein commented Dec 19, 2021

The running Wasm Instance indeed needs to manage its Heap to avoid corruption. Each SVM Template ships with an Export Function named svm_alloc. (the SVM SDK auto-generates that piece of code).

When the Host wants to copy data into the Wasm instance, it calls the svm_alloc exported function and passes the number of bytes it's willing to allocate as input. The Wasm Instance will internally execute the Allocator and return the Memory Address to the first byte of the allocated data.

I think that your proposal could work, but I don't think it's the path we need to take here.

A binary Transaction that executes code (i.e., of type Spawn or Call) will be within 100 bytes to 200 bytes in terms of numbers (less than 1KB).

The calldata always exists. It just depends on whether it's a verify or "the function" to run. (also, we might end up having authorize as well).

If we execute the verify, then calldata relevant for it would be the verifydata. If it's the transaction function, then its calldata will be the funcdata and so on.

The point is that there's a blob to be passed to the running function. This calldata is a blob of size within 0 bytes - 50 bytes and is part of the transaction. So ending up copying the whole transaction in one ago will be pretty much of the same speed.

That said, I think your proposal will involve not only adding more host functions (and the maintenance costs), but I'm not sure it would be even more performant.

If you have a pointer to Wasm memory, the machine code that we'll get after compiling the Wasm into machine code will use the CPU memory reads instructions. On the other hand, calling host functions will result in a machine code abiding by the Operating-System calling-convention.

It means calling a function, pushing the registers, and everything part of the Operating-System's calling-convention ABI. There is also the way back when returning from a called function, resulting in more instructions. Unfortunately, the host functions can't be inlined into the calling Wasm code.

Another matter. Say that you want to use the Wasm References somehow, it means that the host functions will have to allocate some struct (Rust struct in our case) and then move it to the Heap of the SVM Process since this data needs to live-throughout the Wasm running function.

Not only that, this reference will have to be looked at by some key, so we'll probably use some form of a Map (Rust HashMap) to store that mapping which will cost more CPU time.

It's true that the Sigdata is relatively simple and that it'll probably have the Signatures laid out serially. However, I'd still instead let each Template decide how complicated it'd like to interpret this data.

And again, still have the calldata of the running function. So the decision is whether we want to copy a small part of the transaction or the whole transaction (which is also of small size).

We can always add more host functions if we want. However, I think staying with the current proposal is the right thing when weighing everything.

Lastly, since I won't implement this, I'd rather not participate in this discussion anymore. I believe that each side has written his opinion.

I know that not everyone will agree with my last comment. Still, I genuinely believe that we need to make a more apparent distinction between the requirements of features and the lower-level implementation details.

The SVM team (in this case, it'll only be @neysofu, but of course, soon more people will join him) knows the lower-level stuff: the SVM code, Rust, Wasm, and related. And above all, own the component. That's why I recommend that the SVM team take the SVM lower-level implementation decisions.

Nothing is perfect, and everything is about trade-offs (assigning responsibilities, reaching consensus, sense of urgency, time-to-market, future maintenance, simplicity, etc.), but this is how I believe such decisions should be taken.

@avive @tomerafek @moshababo @lrettig @neysofu

@countvonzero countvonzero closed this as not planned Won't fix, can't repro, duplicate, stale Jun 24, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
svm SMIPs related to SVM
Projects
None yet
Development

No branches or pull requests

5 participants