Product
Hello, this is Jaewoo Kim, who works as an engineer for developing Nadya, the programming language specialized for HPC. In this article, I will talk about general language features that Nadya uses to treat its own data, and basic semantics of it. After that, I will explain how it can be modeled in MLIR, with several examples. Hope this article can help someone who wants to understand architecture of Nadya, and how general programming can be modeled using MLIR infrastructure.
Jaewoo Kim
March 10, 2025
Hello, this is Jaewoo Kim, who works as an engineer for developing Nadya, the programming language specialized for HPC. In this article, I will talk about general language features that Nadya uses to treat its own data, and basic semantics of it. After that, I will explain how it can be modeled in MLIR, with several examples. Hope this article can help someone who wants to understand architecture of Nadya, and how general programming can be modeled using MLIR infrastructure.
Implementing semantics for general language
Modern programming languages have several rules for treating the data. Of course, each language have different rules for doing so, but in general ways, most of them are similar (at least for compiled languages such as C++, Rust .etc)
Here are several operations that counts.
Operations
copy
: Deep copies (or clones) a value, decoupling new value with the source value. Copied values can be treated completely separately with the original value.move
: move all contents of the value to other value, and invalidate the source value.borrow
: create loan of otherl-value
(stack value) , which means borrowed value is “owned” by the source value. Typically modeled as stack address of thel-value
access
: de-borrow the borrowed value, which can return originall-value
from the borrowed value. However,l-value
generated by access operation considers it’s lifetime is still borrowed from the original value. In other words, this value cannot be dropped out instead the owner of the borrowed value it was accessed from.assign
: Replace value of the mutable let-binding to the new value.assign
can only be done on l-values, since it’s semantic replaces value stored on the stack memory. If let-binding is immutable, trying to assign something will cause compile error.
Copy
Copying the value creates complete new instance of the value, which does not have any relationship from the source. Copied value is considered as “Owned” value, which means it can be deleted, moved freely without having any restrictions. To be more specific, values that can be copied with simple byte-copy is considered as “trivial copyable”. Otherwise, type requires to implement “Copy” trait, which requires user or compiler (if type is builtin) to implement handler for copying the whole data.
Move
It would be beneficial to avoid deep-copy as possible in the program. also, It’s useful if we could make references to the value. In Nadya, such process is performed by move
and borrow
semantics.
For example, if we move the tensor to other value as following example, internal contents of value a
is now possessed by value b
. This process does not require deep-copy. It’s enough to do this with shallow copy. Therefore, nadyaCore.move
operation is equivalent to llvm.load
operation internally, but it exists to abstract this in higher level scope.
Borrow
Lifetime of borrowed variable such as b
is bound to the lifetime of a
. Which means, b
cannot outlive the scope of a
, and a
cannot be destructed before b
. This restriction is guaranteed by internal verification mechanism implemented in Nadya compiler.
When borrow
is compiled to llvm-level, it’s actually no-op. It’s rather represented as an opaque ptr pointing to the stack address of a
. Since borrowed value is ptr to it’s owner value, value that is going to be borrowed (owner value) must be an l-value.
For instance, the following code is not valid in Nadya. (This is possible in Rust, but it also creates l-value internally. In Nadya, we explicitly mark this as an error.)
Access
Access operation de-borrows borrowed type and returns its source type. You can think of this as accessing pointer from the value. However, lifetime analysis algorithm of Nadya still considers de-borrowed value to be dependent of its source value. In the above example, lifetime of accessed
is still considered as borrowed value of a
. Which means, a
will not get dropped before accessed
is dropped.
Assign
Assign operation replaces value stored on the stack to the new value. The mut
keyword in the let binding means it’s stack memory can be completely or partially replaced to something else. (Don’t be confused with &mut
on the right hand side)
Implementing general language features using MLIR
As a backend technology, Nadya uses MLIR, a compiler infrastructure that allows the definition and customization of multi-level IRs. MLIR is truly a powerful infrastructure for developing languages, since it can abstract complicated operations with IRs defined in different abstraction levels. It also implements lots of default IRs (dialects) that can perform optimization on different abstraction level. While MLIR infrastructure makes it possible to implement a general-purpose language, MLIR lacks default IR for modeling the general purpose language directly.
To implement a general purpose language, we should clarify the following concepts
Which values are on stack, and which values are not?
How are the values related to each other in memory?
How to copy & delete data types properly
(For Nadya) How can we benefit the optimization with this higher-level concept?
However, by default, MLIR lacks ability to model such kind of concepts. Therefore, we had to define one ourselves. Let me discuss how we did this, and propose this concept to MLIR community for other potential language developers around the globe.
Separating l-values and r-values
What we first thought of is separation between l-value and r-value.
For example, look at this Nadya program.
Conceptually, a
and b
would be pushed onto the stack memory, while 10
and 20
are temporary constants.
Here, values like 10
and 20
are called r-value, while a
and b
are called l-value.
Biggest difference between the two is lifetime. r-values are immediate values, which are destructed after use, while l-values live until the scope ends (End of the add function in this example) so it can be used later in the same scope.
Which means, 10
and 20
should be pushed onto the stack, and deleted immediately. (Let’s not think about the optimization for now).
When executing this function, program stack would look something like
When performing a + b + arg
later, compiler should load all arg
, a
, b
to the memory as an r-value, and add them together.
Actually, simple case like this can be modeled easily using llvm IR directly, since it contains llvm.alloca
(allocates memory on stack), llvm.store
(stores value on the allocated stack) and llvm.load
(loads value from the stack). We call these kind of types “trivially copyable” which means values can be copied completely with byte manipulation (simple memory copy).
For trivially copyable types, shallow copy and deep-copy does not give any kind of difference. (We call shallow-copy “move” and deep-copy “copy” in Nadya)
Why do we need special modeling over low-level llvm IR?
However, most general programming languages use more complicated types such as Strings, User defined structs, lists and arrays, etc. For example, in Nadya, type may define custom implementations for copy & drop operations.
This example shows simple data wrapper implemented in Nadya.
Internally, DataWrapper
in C++, would look something like
In this case, shallow copy (move) and deep copy (copy) would be different. While deep-copy is defined to copy the whole contents of the data, shallow copy will only copy the ptr value itself. If we had to write copy & drop semantics of this struct directly in LLVM, it would be too complicated.
Therefore, we developed our own model for modeling this case, using nadyaCore
dialect.
nadyaCore
dialect provides the following functionalities
push
operation: loads r-value onto the stack and creates l-valuecopy
operation: deep-copies arbitrary typedrop
operation: destructs arbitrary typemove
operation: shallow-copies the value. Lifetime of the source is invalidated since value is considered to be moved out.borrow
operation: creates r-value which references other l-value
Now, let’s see how it works by implementing the entry point of the program. (In Nadya, entrypoint of the executable is nadya_main
)
Executing this with ndc example.ndy -o example
gives the following result (compiled without optimization)
This is the abstracted MLIR output converted from the AST
As we can find out, this concept can be abstracted using MLIR pretty easily. Nadya uses this concept of l-value
and r-value
to handle data dependencies.
Modeling general programming semantics is not easy, but MLIR provides great infrastructure
As we explained, MLIR can be used to abstract the general programming features, and it can express the semantics of Nadya programming language in simple way. With MLIR’s builtin analysis tools and customizable pass pipelines, MLIR provides strong infrastructure for building programming languages. Personally, I think if people can get more interested in extending MLIR for more general purpose other than optimizing AI workloads, there can be a lot more potential for the framework.