Rv32im + ecall as the lowest-level ISA/API

Preface

We want to be able to run (parts of) Anoma programs in/on many base environments:

  • on existing blockchain VMs, such as the EVM and SVM
  • on zkVMs, such as RISC0
  • in the browser environment (best application distribution vector)
  • on regular desktop operating systems
  • on mobile operating systems (in the future)

At the moment, we do not have a single standardized low-level ISA/API that is supported in all of these environments (which are themselves quite heterogeneous). Instead, we have:

  • With Galileo/APv1: “backend” application code in Solidity and Rust → RISC0 (resource logics) (runs on EVM/RISC0), “frontend” application code in a mix of Javascript and Rust → WASM (runs in the browser), “service” application code in a mix of Rust and Elixir (runs on a Linux server).
  • With the local domain + controller software: Elixir (runs on desktop only).
  • Old work on Anockma (Nock) and Cairo (another zkVM).

If we had a single standardized ISA/API which could be supported in all of these environments, we could greatly reduce the variety of different components needed to write an end-to-end Anoma application. This is closely related to the project of a low-level API, although note that here I’m talking about an even lower-level API (i.e. the RM and other application abstractions would be built on top of this layer).

Of these environments, by far the most performance-constrained are existing blockchain VMs, zkVMs, and the browser. I do not think that we need the most performant option to start – we can get away with something that isn’t too awful and optimize particular applications if they turn out to be popular. If we can find an option that works for those three, making it work on desktop and mobile operating systems should not be a problem.

Proposal

I propose that we use rv32im + ecalls (RISC-V “syscall” interface) as a standardized “low-level ISA”, where, more specifically:

  • The basic rv32im instruction set provides 32-bit integer computation, memory, and control flow
  • We define a set of custom ecalls (syscalls), including:
    • System calls which provide optimized cryptographic operations (e.g. BLS12-381 elliptic curve operations) (similar to what RISC0 does)
    • System calls which provide local persistent storage operations (read/write/scan)
    • System calls which provide network operations (send message to pub/sub topic or node)
    • System calls which provide distributed storage operations (scry)

All environments would need to support the basic rv32im instruction set, but they could support different subsets of system calls (and implement them differently), and authors/users of programs (or the compilers / other software acting on their behalf) will need to keep this in mind.

For example, both the SVM and EVM support bn254 elliptic curve operations as “precompiles”, but only the EVM supports BLS12-381 operations, so our BLS12-381 syscalls would not be supported by the SVM “Anoma low-level runtime”.

Further abstractions such as the resource machine, object systems, Anoma-level ideas, AVM ideas, etc. would be built on top of this base level.

In general, it appears that (with sufficient usage of ecall for key operations) this is feasible:

  • rv32im is supported by RISC0 and other zkVMs
  • rv32im can be compiled to the EVM and SVM with moderate overhead
  • rv32im can be run in the browser
  • I’m sure that we can manage desktop and mobile (although I haven’t investigated in detail).
  • We can (relatively) easily compile a simple Scheme to rv32im, and efforts are underway to compile a subset of BEAM.

The abstraction layers would then look roughly as follows:

I think that this proposal is concordant with the ideas in Thoughts on a low-level Anoma API and A Roadmap for AL, and welcome feedback.

3 Likes

Exactly what sorts of syscalls would we need? So far, I can think of the following:

  • Optimized cryptographic operations: elliptic curve operations, hash functions, etc. These could all be implemented in rv32im directly and are just more efficient as specially optimized operations (this is very similar to jets in Nock). Each environment would support a subset of these (probably all of them for browser/desktop/mobile, limited for zkVM/EVM/SVM).
  • Local storage operations: read from, write to, and scan “local” storage (persistent memory).
    • Implemented by localStorage or similar in the browser enviroment.
    • Implemented by persistent storage on EVM/SVM environments.
    • Not supported by zkVM environment.
    • Implemented by persistent DB storage on desktop/mobile.
  • Network communication: send message to Anoma network address, send message to pub/sub topic (maybe these can be combined, maybe not).
    • Implemented by relay from the browser environment.
    • Implemented by relay from EVM/SVM environments (log an event which a listening node will pick up and relay the message).
    • Implemented by direct network communications on desktop/mobile.
    • Not supported by zkVM environment.
  • Distributed storage operations: scry distributed storage (with scry-properties).
    • Implemented by the zkVM/browser/desktop/mobile runtimes (this is the most complicated syscall).
    • Not supported by EVM/SVM (except for maybe very simple scries).
2 Likes

Thanks for this! It is an interesting proposition. I have two questions:

  • How do you envision the ecalls would practically exist in relation to RV32IM here- for example, to what extent will this introduce uncertainty regarding which programs are safe from a ZK perspective? Also, when you say some of the cryptographic operations can be implemented in RV32IM, is that to say that they do not require, for example, floating points? I believe there’s an entire RISC-V extension for cryptographic modules, but I’m not expert.

  • Also, could we make explicit what is being left remaining out from RISC-V if we’re re-introducing this set of syscalls into RV32IM, maybe regularly used extensions or syscalls? I can’t recall all of the various extensions off the top of my head.

1 Like

Why do these require to be sys calls it seems premature to consider them as such, likely but ontop of some other calls in AL.

For example nock doesn’t formally define 12, it’s a meta operation implemented in a self reflective compiler. I’m not saying we ought not to do it, but rather note the capability of the system and we might find better ways to achieve it than fix it into an instruction set. How our scry works is an interface and would likely be bootstrapped with different implementations meaning a more primitive capability would be needed rather than something powerful

2 Likes

Good point (also discussed with @l4e21), they don’t need to be, although we’ll need to think about how scry would work in different environments.

Here, we are mainly interested in the smallest steps of computation[1], in particular in post-ordering execution.

For post-ordering execution, the most important aspect that is already covered above is the sending of messages over the network. Now, I wonder @isheff, in the context of this post on a low-level API for controllers: would we want to separate out the writes to blob storage into a separate category of load/store operations or would they be subsumed by the above mentioned operations on

?


  1. Typically, I would add in a single thread, but that may be clear from the general context. ↩︎

There are already many projects as you pointed out that support RV32IM , interpreters in the zkVM, the browsers (although why not WASM, which can be compiled to riscv), and others. That’s good, indeed. Also, looking for a standard for Anoma lowest-level ISA based on an already standard + extension smells good, also reminds me as you linked one we research on Cairo ISA and its algebraic RISC.


On syscalls for cryptographic operations, skimming some websites, this seems that it’s better to have precompiles, otherwise is expensive? not expert either, but it should be declarative and connect to the next point (as it’s backend specific).


Treating scry as a syscall is a bad design decision. It goes against the layering I propose in the AVM. I strongly agree with mariari:

In the current AVM design, scry is a declarative query—the runtime decides how to implement it (distributed hash table, content-addressed storage, etc.). Reducing it to a key-value syscall loses this flexibility.


“what level of abstraction should the “standard API” target?” Tobias’ comment above is perfect.

so, I suggest, perhaps something like High-level (AVM) -> AL (acting as os-lang) -> (zkVM/browser target? This way, we preserve the AVM’s semantic richness + type safety :stuck_out_tongue: while enabling compilation to RV32IM + precompiles for zkVM deployment (on demand).


Do you have any snippet that demostrates how to actually target rv32im+ecall??

2 Likes

Yes, agreed, hence the idea here for flexible ecalls/syscalls.

Makes sense :+1:

I think in broad strokes the layering (from high-level to low-level) of:

  • Typesystem(s), which is/are built on top of:
  • Anoma-level, which compiles to:
  • ALLIR (rv32im+ecall/precompiles)

makes sense. While I think that the research and development work was useful, I no longer think that we should use the term or concept “AVM”, it’s just too vague (this one’s on me historically).

1 Like

Well, we shouldn’t call a type system built in/ontop AL higher level than AL. It is after all just a normal predicate in the language that can be invoked, meaning that it is itself just some AL code.

Thus the lower levels would be whatever ALM (AL machine code, probably some kind of WAM like machinery, not designed nor thought of concretely yet) code AL generates and goes ontop of rv32im+precompiles if we wish to go in the rv32im direction.

I think it is important to explicate on the matter of AL and how live systems tend to be built, as I think it will explain how AL can be extended and how it ought to be built.

Namely the core of a live system has to be simple (See some future writing I have on this point) as we have to account for how the system can change under us, thus having a lot of orthogonal features at a core part of the system gives us that many more cases to handle in a correct way. Doing this poorly for some features of the system means that those parts of the system can’t be used in a live way making the livness of the system moot (might as well boot it).

However this brings up another problem, if handling a lot of orthogonal features is rough, how do we manage to get high level computation then? Trying to add the features ontop one by one brings us to the same problem, each of those features have to worry about how it interacts with the system!!! Thus the only approach that works well is by keeping a simple core that can extend itself upwards!

We can see this approach work successfully in practice in the following examples:

  1. The implementation of Self paper covers just how simple a prototypical object oreinted model is. There is almost nothing to the implementation and yet you get self which is a more dynamic version of smalltalk (the paper uses self and not smalltalk to show how they can optimize in a harder environment than smalltalk!).
  2. The Simple Reflective Kernel Paper shows how you can make an entire meta reflective object system in less than 32 methods. This model is more akin to how smalltalk actually works at a protocol level, and shows what barebones bootstrapping can give you. This paper answers the question on how to answer infinite recursion questions for a consistent environment. On the power scale, you can derive things like Abstract clases that are baked into java in 3 lines of code!
  3. Forth is the simplest family of programming languages that can be bootstrapped in raw assembly very easily (see my video on Jones Forth). It ends up feeling a lot higher level than C because it is so consistent. Things like lexical variables can be dervied. Forth’s computation model is a lot simpler than the object models and thus has some issues scaling with certain techniques (though Factor shows that with the right abstractions ontop it actually is as high level as Smalltalk or Lisp!).
  4. Common Lisp. Although it has 26 special forms (think functions with special behaviour you typically can’t derive within the standard langauge) and a standard over 1000 pages, it is designed in such a way very deep extensions can be had. A lot of the standard are just functions that are built ontop (no different from having a large standard library. And it remains extensible as those 26 special forms are known upfront and we can reduce every term to either a function or a special form. This allows users to write their own tools like a codewalker.
    • A lot of the reason why the language is extensible is that each set of abstractions (layers if you will) are built and reified inside the language and build reflective APIs that the force the language developer to develop the feature as if they were users. Two good books on the subject can be found here:
  5. Prolog. Prolog is built on a complicated WAM machine, but after this level the language then composes all of itself traditionally. Handling the top level and loading of programs well.

This list is non exhaustive, as languages like APL and Erlang also fit this philosophy of design. In fact it is hard to find languages which can be developed and deployed interactively that don’t work this way. If we extend our definition of liveness to just system execution and not the language with it then systems like the JVM with Java and C under Unix are examples since they can be operated live (the JVM can dynamically load Java, C has DLL files, etc).

Therefore since we wish to make a truly live system in Anoma, AL must be written in a way that allows us to successfully achieve this, which means the following:

  1. The language of the system is AL, most everything in the system will be written in it (Everything for Unix at the end of the day goes back to C. for the Erlang virtual machine Erlang sets the stage and languages like Elixir follow how the system works).
  2. AL will have to compile to something, this will be AL Machine (or ALM for short) code. This will probably look like the WAM, but details are not clear yet
  3. ALM can most likely be compiled to something like rv32im+ecall/precompiles (per this thread).
  4. These 3 are the only major layers, there are not foreign systems below or above it like the AVM, as if we wish to make a truly live system it has to be holisitc in design. Adding layers which do not naturally evolve within the layers creates friction points that will lead to a loss of livness in design. The compiler may for optimization reasons may create additional layers in practice but that is an implementation detail (Sea of Nodes for ALM!?!?).
    • It’s the system language of Anoma, thus it’s a low level language! The reflective nature of the abstractions means that we can hack the system at a very low level!
    • It’s reflective thus it’s a high level language! We can have layers of abstraction without losing power as the layers of abstraction are designed in the school of thought as Genera/Common Lisp meaning that if we want special behaviour we have to us our own mechanisms to achieve that. (the language/system designers are not special).
  5. Since AL is reflective, we can make things like type checkers as functions. We can design pluggable types.
    • Elixir’s new type system is interesting as it’ll introduce a rather nice static type checker to Elixir, meaning there are 2 type systems can employ in Elixir. If one is interested i can talk about nuances of where this does and does not fail to be live.
  6. The system around AL will be the local domain, this is the programming environment (also led by @l4e21) around AL. From this things like controllers will be written (currently in Elixir in our Elixir local domain).
4 Likes

All of this makes sense to me, and that the core should be small and extendable, with different layers or capabilities. Since I’ve heard your ideas about AL for a few years now, and after the last HHHs, what I’d like to see next are clear design write‑ups and a full implementation that can be tested locally—rather than descriptions of what “will” be included or what you “want” or more plans rather than actions. See the need:

So, while those plans are possible, bringing AL to the table would always raise concerns for me, including how our limited manpower at Anoma and across the other language projects might conflict and where we would end up.

That being said, I’d expect the final outcome of this forum post to be in a better format, similar to my AVM shares: specs, assumptions, implementation details, limitations, and examples—something tangible, an MVP. I think it’s doable, considering you can fork a system like Pharo, add backends, and so on, at least based on a few sessions with Pharo, GT, and Smalltalk while studying its object system for the AVM.


That’s what I like about Chris’s proposal: building on a well-known standard, not on will-projects, so that the outcome can leverage all the existing ecosystem tomorrow.


Anyway, I believe you’ve already made some progress on an Elixir prototype, though I’m not sure whether it’s finished.

Really, take this as a friendly nudge to get AL shipped, if you really believe in it.

3 Likes

The problem of man power is an interesting one. If we wish to realize a custom controller then we are in essence taking on the role of implementing an operating system. And in doing so all this work is needed anyways. Doing proper design for the system we want means that we have to do less work overall.

Well, proposals are a bit weaker than wills, as will means that is the direction we are taking and going in, whereas simply planning to implement something standard does mean we still have to do the labour of getting to that point. Thankfully the rv32im work has been planned with AL in mind. In particular the AL track of work has been planned in two phases previously:

  1. Get something useful for the organization quickly
  2. Get important research we wish to get done, done so we can work on the long term system.

We’ve made some good progress on both fronts.

On the short term front, we have been doing compiler work from elixir to rv32im. This is useful for deploying programs to Anoma in Elixir. Since we plan to write AL in Elixir and write to data structures in Elixir, we can put both our compiled programs and the compiler online. Meaning that this is a meaningful step towards proper Anoma deployment. Another point is that we’ve been implementing APIs and data flows that will be present in AL and isn’t currently common within Elixir practice, meaning that one “can program in the future AL Today” (as @ray puts it).

This can be seen here:

On the long term front a lot of ideation has been happening and ideas have been fleshed out thanks to @l4e21. Work can be seen on this front in a few areas:

There are of course more in the works as there is an essay about livness and event sourcing in the works.

What is nice is that there has been a shift, previously I was told those on engineering can only work on AL after a year and a half, and since engineering is the department with the technical expertise for such a project it’s mostly stayed ideating. However since priorities have shifted we can actually work on the operating system language of anoma, which has been long overdue.

As an organization I do believe we can ship some good versions of AL.

4 Likes

Hi. I’m trying to think through implementation details and might not be sufficiently accounting for the broader vision… Some potential drawbacks that come to mind when compiling rv32im to EVM bytecode:

  • Wouldn’t compiling JALR instructions require a dispatch table (that maps instruction addresses for the rvim32 binary to those for the EVM bytecode)? If so, it might be easier to compile from a higher level representation where instruction offsets are not yet fixed. This way, registers would already contain valid EVM bytecode addresses and a JUMP (without the dispatch table used in rv32im-evm) can be directly done.
  • Wouldn’t 256 bit value arithmetic be harder to compile efficiently to EVM bytecode? My intuition here is that register in rvim32 are 32 bits wide, so whatever information the original source code may have provided about the 256 bit arithmetic it wanted done would be lost. Whereas if the input was more higher level than rv32im, we could directly/efficiently generate 256 bit arithmetic operations.
  • The “register allocation” might be a little more inefficient. This is because rvim32 has 32 general purpose registers whereas EVM bytecode only has 16 accessible registers at a given point in time. So the result is two layers of register allocation, the first being from the original source code to rv32im, and the second being in the mapping to EVM bytecode. It might be more optimal map directly from source code variables to stack slots.
  • What concrete form does the input rv32im code take? If the input is textual RISC V assembly code, then our compiler to EVM bytecode would need to be used before any linker (since linkers produce binary code). We would then need to think about how output EVM bytecode is linked together. If the input is an RISC V ELF file, we might have to think about parsing ELF files (including symbol and relocation tables and data sections). It might be easier for us and potential users to extend an existing compiler.

Because of the above, I suspect that there will be practical code (heavily using 256-bit arithmetic, function pointers, and many local variables) for which the proposed RISC V IR produces gas inefficient Soalana/Ethereum programs. Have we considered alternative IRs like LLVM or anything else? LLVM IR, for example, might already cover most of the above targets listed above if the following existing compilation processes are used:

  • Solana: Rust → LLVM IR → eBPF bytecode
  • RISC Zero: Rust → LLVM IR → RISC V (@Christophe raised this point early on in the context of Elixir to RISC Zero compilation)
  • Web: Rust → LLVM IR → WASM
  • Desktop: Rust → LLVM IR → Native executable
  • Ethereum (outlier): Solidity → Yul → EVM bytecode

The main argument for LLVM IR would be that it minimizes the chances that we fail to make correct compilers that generate performant/efficient code for each of the listed targets. The justification for this argument is that most of the work for Solana, RISC Zero, the web, and desktop has already been done for us. And for the case of Ethereum, a higher level input language like LLVM would probably maximize the chances of a simple compilation that produces performant EVM bytecode. On this note, three strategies come to mind for compiling LLVM IR to EVM bytecode:

  • LLVM IR → EVM bytecode directly. This has been researched before in: GitHub - etclabscore/evm_llvm: Official repo of the EVM LLVM project . I think this is also currently being researched in GitHub - NomicFoundation/solx: LLVM-based Solidity compiler. . So we can more quickly assess whether this approach is workable.
  • LLVM IR → RISC V → EVM bytecode. LLVM IR to rv32im is a solved problem, so the work/experimentation would probably be in using the above GitHub - cwgoes/rv32im-evm to compile the rv32im to EVM bytecode (with dispatch tables and so on). The broader point is that if RISC V → EVM bytecode is viable, then LLVM IR → EVM bytecode is also viable (but the converse might not be true). So some of the ideas/experiments raised in earlier posts are not inconsistent with LLVM IR.
  • LLVM IR → Yul → EVM bytecode. I.e. let the Yul compiler handle stackification of local variables and gas related optimizations. The drawback here is that LLVM IR allows spaghetti code while Yul only allows structured control flow, so compilation from the former to the latter would require some structuring of the control flow. This is probably tractable since this is what is done when compiling LLVM IR to WASM (using the Relooper algorithm). Such a transformation might not blow up the gas costs because Ethereum control flow instructions are cheap relative to memory accesses.

LLVM IR was just the first thing that came to my mind, but I guess any IR that is (1) higher level (to avoid above compilation drawbacks) and (2) already supported by a majority our input languages and target architectures could potentially ease our implementation work and produce more performant results (especially for Solana and Ethereum). cc @cwgoes @ArtemG

2 Likes

I don’t personally have anything against using LLVM here.

Generally speaking, my personal main goal for the compiler stack is safety/verifiability which means the more standard community-accepted pre-made solutions we use the better. If there are existing project utilizing LLVM that may play well with this position.

For RISC Zero is it LLVM IR they are processing? I’m unsure of how the c compilation works in the work you’ve done.

As for the rest, I don’t think just compiling to LLVM to WASM does come with restrictions so for example CLASP (common lisp that compiles to LLVM) does not work in the web browser.

For Solana as well, I believe they use a fork of LLVM, we do know not all of Rust interacts with it I’m unsure why though.

For the Desktop, I’m less concerned about this one. Mainly because LLVM is an intermediate representation, meaning that as you’ve shown we have to compile it further from there. Meaning that if we say have AL ontop of this that outputs ALLIR, then we likely can execute it either way without much effort.

This gets into a concern or question I have for @cwgoes, are you looking for binary compatible or is a high level format good enough? I think this depends on a few properties we have.

This is not to say I’m against considering LLVM, however I do have a few concerns about LLVM since I do happen to know about developing against it:

  1. Will we have to build versions of LLVM and that entire tool chain to get things working? LLVM is a massive beast and has wasted a lot of my work time having to build specific versions to get things to work. It as a dependency ends up being very annoying to deal with if you ever have to include it and will slow down development process.
  2. Can we ensure our compilation pipeline won’t slow down? LLVM is a beast and has not been optimized at all for running fast, it tends to slow down getting code from any project that uses it. I’m unaware of any solutions to this problem.

Not really a concern but how do you imagine using LLVM here, it can have functions, however I’m unsure on how to dynamically update them, so for things coming in we wouldn’t compile them as LLVM functions. If we can use functions we can maybe add custom GC directive backends into LLVM (this is offered by LLVM, for people to customize). I’d just be curious on your view of the life cycle above this. I kinda understand how to evaluate assembly instructions dynamically, I’m just unsure how to do the same with LLVM, and understanding that would put my mind a bit more at ease. It’s not impossible to fix the live issue, I know Julia and CLASP do it, I’m just personally unsure on how it would work without going around the LLVM model, a failure on my end I’m sure.

On the note of higher representations, if we do accept intermediate targets like LLVM, then could we not do something simpler? Either find something else off the shelf or something with low level enough semantics where we can do the work to make it work?

EDIT ::

I’m asking a chat bot about my personal failure here, I’m including a link to the chat for those who are interested in trying to use LLVM live:

https://claude.ai/share/0855e142-1f34-46f0-b9db-3c1633879e3e

EDIT 2 ::

There seems to be some bindings in Elixir to LLVM generation: