Introduction
Marlin is a compiled, statically typed programming language built on MLIR and LLVM. Its goal is simple: one readable code base that runs on all CPU cores and on the GPU, without rewriting anything in a second language.
- Concurrency is part of the language.
parallel for,marltasks and@GPUfunctions are keywords, not libraries. - The compiler checks the dangerous parts. Parallel loops that could race, tasks that write to globals and GPU code that touches host-only data are rejected at compile time.
- Batteries included. Strings, lists, maps, matrices, structs, files, JSON, an HTTP/1.1 server and client, PostgreSQL and SQLite.
- Native output. Programs compile to optimised machine code for Linux, macOS and Windows. There is no virtual machine, and the result is a single file you can copy to another machine.
Marlin is a young research language and is developing quickly. This guide describes what works today; the roadmap lists what is still missing.
Getting started
Install
One command downloads the compiler for your system from the latest release, verifies its SHA-256 checksum, installs it into your home folder and adds marlin to your PATH. Running it again updates Marlin.
$ curl -fsSL https://github.com/MehmetEminKaymaz/marlin/releases/latest/download/install.sh | sh
PS> irm https://github.com/MehmetEminKaymaz/marlin/releases/latest/download/install.ps1 | iex
Marlin goes into ~/.marlin (on Windows %LOCALAPPDATA%\Programs\Marlin). Open a new terminal afterwards so that the PATH change takes effect. To install by hand, download the archive for your system from the download page, unpack it anywhere and add its bin folder to PATH.
Requirements
Marlin compiles your program to machine code and uses your system's C compiler to link it into an executable.
| System | Supported | You also need |
|---|---|---|
| Linux | x86-64 and ARM64, glibc 2.31 or newer (Ubuntu 20.04, Debian 11, Fedora, Arch …) | a C compiler: sudo apt install build-essential, sudo dnf install gcc or sudo pacman -S base-devel |
| macOS | Apple silicon and Intel | the Command Line Tools: xcode-select --install |
| Windows | 10 and 11, x64 | Visual Studio Build Tools with the Desktop development with C++ workload and the C++ Clang tools component (the command is below) |
PS> winget install Microsoft.VisualStudio.2022.BuildTools --override "--passive --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --includeRecommended"
Optional extras, only needed for the features that use them:
- GPU: any graphics card with an up-to-date driver.
@GPUcode runs through Vulkan on NVIDIA, AMD and Intel GPUs and through Metal on Macs. On NVIDIA cards, installing the CUDA Toolkit adds native CUDA kernels. Without a GPU the same program runs on the CPU. std.postgres: the PostgreSQL client library:sudo apt install libpq5on Linux,brew install libpqon macOS. It ships with Marlin on Windows.std.http.client: libcurl, which is already installed on most Linux systems and on every Mac. It ships with Marlin on Windows.
Check your setup
marlin doctor compiles and runs a few small programs, then reports what works and what is missing, with the command to fix it. Lines marked [HATA] (error) must be fixed before programs can be built; [uyari] (warning) only affects the feature it names.
$ marlin doctor [tamam] baglayici (C derleyicisi): /usr/bin/cc [tamam] deneme programi: derlendi ve calisti (paralel dongu dahil) [tamam] GPU calisirken: cuda (NVIDIA GeForce RTX 4060 Laptop GPU) [tamam] std.http.client (libcurl): kutuphane yuklenebiliyor [uyari] std.postgres (libpq): libpq ... bulunamadi (kurulum: ... 'sudo apt install libpq5' ...) Kurulum calisiyor. [uyari] satirlari yalnizca ilgili ozellikleri etkiler.
(The compiler's messages are currently written in Turkish: tamam = OK, uyari = warning, hata = error.)
Your first program
func main() -> void {
int total = 0;
for i in 0 .. 5 {
total = total + i;
}
print("total =", total);
print("root:", sqrt(total));
}
$ marlin hello.marl -o hello $ ./hello total = 10 root: 3.1622776601683795
Without -o the executable is called a.out (a.exe on Windows). The result is a single native file: you can copy it to another machine of the same kind and run it there, no Marlin needed. Compile errors point at the file, line and column, for example hello.marl:3:13: ....
Update and uninstall
To update, run the install command again. To remove Marlin:
# Linux / macOS $ curl -fsSL https://github.com/MehmetEminKaymaz/marlin/releases/latest/download/install.sh | sh -s -- --uninstall # Windows (PowerShell) PS> $env:MARLIN_UNINSTALL = "1"; irm https://github.com/MehmetEminKaymaz/marlin/releases/latest/download/install.ps1 | iex
Building from source
You only need this to work on the compiler itself. Requirements: LLVM / MLIR 19.1.7 (the conda-forge packages mlir and llvmdev are the easiest way to get them), CMake 3.25+, a C++20 compiler and Java 11+ for the ANTLR parser generator. Then:
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMLIR_DIR=/path/to/lib/cmake/mlir -DMARLIN_ANTLR_FROM_SOURCE=ON $ cmake --build build -j $ ./build/marlin doctor
The repository's README explains the test suite and how the release packages are made.
Compiler options
marlin [source.marl] [options] marlin doctor
| Option | What it does |
|---|---|
-o file | Name of the output file |
-O0 … -O3 | Optimisation level. The default is -O0. |
--emit=… | Stop at an intermediate stage (see below) |
-I dir | Extra directory to search for imported modules (can be repeated) |
--gpu=auto | The default. @GPU functions are compiled for the GPU and for the CPU; at run time the program uses the GPU if there is one (CUDA on NVIDIA, Vulkan elsewhere) and the CPU copy otherwise. |
--gpu=on | Like auto, but warns when the CUDA toolkit is missing, so NVIDIA cards would use Vulkan. |
--gpu=off | Compile @GPU functions for the CPU only. Useful for comparing results. |
--print-ast | Print the syntax tree |
--lsp | Run as a language server (used by the editor extension) |
--version | Print the compiler version |
doctor | Check the installation (linker, GPU, libraries) and explain how to fix what is missing |
Looking inside: --emit
The compiler transforms your program step by step: .marl → AST → MLIR → LLVM dialect → LLVM IR → object file → executable. You can stop at any step and look at the result.
| Value | Shows |
|---|---|
mlir | High-level MLIR (scf, tensor, async …) |
gpu-mlir | MLIR after GPU outlining: every kernel inside a gpu.module, launched with gpu.launch_func |
llvm-mlir | MLIR lowered to the LLVM dialect |
llvm-ir | LLVM IR, as in .ll files |
obj | Object file, not yet linked |
exe | Executable (the default) |
Editor support
The VS Code extension gives you syntax colouring, snippets, live error reporting and a Compile & Run button. Errors come from the real compiler front end running as a language server (marlin --lsp), so the editor and the compiler always agree.
- Install Marlin first (see Getting started); the extension uses the
marlincommand on your PATH. - Download
marlin-vscode.vsixfrom the download page. - In VS Code's Extensions panel, choose … → Install from VSIX… and pick the downloaded file.
- Open any
.marlfile. PressCtrl+Alt+R(or the ▷ button) to compile and run it.
If marlin is not on the PATH that VS Code sees, set Marlin: Compiler Path in the settings. After updating Marlin, restart the language server from the command palette: Marlin: Dil Sunucusunu Yeniden Başlat (the extension's commands are in Turkish for now).
Variables & types
int counter = 0; // globals need a constant initial value
float rate = 0.5;
string last_message; // or no value at all: zero / empty string
func main() -> void {
int x = 5;
let y = 2.5; // the type is inferred (float64)
bool ok = x > 3 && y < 10;
string greeting = "hello";
int[4] arr = [1, 2, 3, 4];
arr[0] = 10;
print(x, y, ok, greeting, arr[0]);
}
| Kind | Types |
|---|---|
| Signed integers | int8 int16 int (32-bit) int64 |
| Unsigned integers | uint8 uint16 uint uint64 |
| Floating point | float (32-bit) float64 |
| Others | bool char string |
| Collections | arrays int[5], lists list<int>, matrices matrix<float, N> |
| Functions | (int) -> int |
| Tasks | task<T> |
| Your own | struct Point { … } |
- Numeric types convert automatically (
int h = 7.5;gives 7). - Conditions must be
bool: writeif x != 0, notif x. - Every program needs a
mainwithout parameters that returnsvoidorint.
Control flow
if x > 10 {
print("big");
} else if x > 3 {
print("medium");
} else {
print("small");
}
for i in 0 .. 10 { } // 0 to 9
for i in 0 .. 10 step 2 { } // 0, 2, 4, 6, 8
for e in arr { } // every element
while x > 0 { x = x - 1; }
// break and continue work in every loop
The loop variable of a for cannot be changed inside the loop, and step must be positive.
Functions & lambdas
func add(int a, int b) -> int {
return a + b;
}
func apply((int) -> int f, int v) -> int {
return f(v);
}
func main() -> void {
let square = (int n) -> int | n * n;
print(square(4), add(2, 3), apply(square, 9));
}
- Functions are values: they can be stored in variables, lists and struct fields, passed to other functions and returned.
- Lambdas use the form
(params) -> type | expression. - Lambdas cannot capture local variables; pass what they need as parameters.
Arrays & lists
list<int> l = [1, 2, 3];
list<float> empty = []; // the type must be written
push(l, 4); // append
int last = pop(l); // remove and return the last element
l[0] = 10;
print(len(l), l[0], last);
for e in l { }
free(l); // lists are released by hand
Array int[5] | List list<int> | |
|---|---|---|
| Size | Fixed in the source | Grows and shrinks (push, pop) |
| Passing to functions | The size is part of the type | Any length |
| Returning from functions | No | Yes |
| Bounds checks | Constant indices, at compile time | Every access, at run time |
- Lists are references.
list<int> b = a;does not copy: both names see the same list, and a function can change a list it receives. - Release lists with
free(l)when you are done. Strings stored inside are released with the list. - A list literal passed straight to a function (
total([1, 2, 3])) is released automatically when the function only borrows it. The compiler works this out for you. - Elements can be numbers,
bool,char, strings, functions, tasks or structs.
Strings
string name = "world";
string hi = "hello " + name; // concatenation creates a new string
print(hi, len(hi), hi[0]); // hello world 11 h
if name == "world" && "abc" < "abd" { } // compared by content
string msg = "x = " + str(42); // str: number, bool or char to string
int64 n = to_int("40") + 2; // to_int / to_float: string to number
float64 f = to_float("2.5");
- Strings are immutable and their memory is managed for you with reference counting. No
freeneeded. - Length and indexing count bytes. Text is UTF-8, so non-ASCII letters take more than one byte.
+joins two strings only; convert numbers withstr(...).to_intandto_floatstop the program with a message if the text is not a number.
Matrices
matrix<float> a = [[1, 2], [3, 4]];
matrix<float> b = ones(2, 2); // zeros(r, c), ones(r, c), fill(r, c, value)
matrix<float> c = a + b * 2; // element-wise; a scalar applies to every element
matrix<float> d = matmul(a, b); // matrix product
c[0, 1] = 7; // [row, column]
print(rows(c), cols(c), sum(c));
print(transpose(a));
matrix<int, 1> v = [1, 2, 3]; // one dimension: a vector
matrix<float, 3> t = zeros(4, 8, 8); // three dimensions
t[0, 2, 5] = 1;
print(shape(t, 0), shape(t, 2)); // 4 8
- The number of dimensions is part of the type (
matrix<T>ismatrix<T, 2>); the length of each dimension is decided at run time. - Matrices are values.
matrix<int> b = a;makes a copy. Memory is managed automatically. + - * /are element-wise. A whole expression such asa + b * 2 - cis fused into a single loop with no temporaries, so writing it as one expression is fastest.- On the CPU,
matmulis spread across all cores. - Shapes and indices are checked at run time with a clear message.
Structs
struct Point {
float64 x;
float64 y;
}
struct Circle {
Point center; // a field can be another struct
float64 r;
list<int> tags;
}
func shift(Point p, float64 dx) -> Point {
p.x = p.x + dx;
return p;
}
func main() -> void {
Point a = Point(1, 2); // fields in declaration order
Point b = a; // a copy
b.x = 10; // a.x is still 1
Point c = shift(a, 5);
print(a.x, c.x, shift(a, 1).x);
Circle cm = Circle(Point(0, 0), 2.5, [1, 2]);
cm.center.x = 7;
push(cm.tags, 3);
}
Fields can be numbers, bool, char, strings, arrays, lists, matrices, functions and other structs. A function field turns a list of structs into a routing table:
struct Command {
string name;
(int) -> int run;
}
list<Command> commands = [Command("square", (int v) -> int | v * v),
Command("double", (int v) -> int | v * 2)];
for i in 0 .. len(commands) {
if commands[i].name == "square" { print(commands[i].run(5)); } // 25
}
list<Point>is a reference, but its elements are values:Point p = l[0];is a copy. Writel[0].x = 9to change the element in place.- A struct whose fields are numbers,
bool,charor strings can be a global (declared without a value, assigned in functions). - Mark a struct
publicto use it from other modules.
Modules
Every .marl file is a module. Imports go at the top of the file.
import tools.vector; // tools/vector.marl
import tools.vector as v; // with a short name
from tools.vector import norm, dot as inner; // names imported directly
func main() -> void {
print(tools.vector.norm(3.0, 4.0));
print(v.norm(3.0, 4.0));
print(norm(3.0, 4.0), inner(1.0, 2.0));
}
public int calls = 0; // other modules can read and write it
float64 SCALE = 1.0; // private
public func norm(float64 x, float64 y) -> float64 { return sqrt(x * x + y * y) * SCALE; }
public func dot(float64 a, float64 b) -> float64 { return a * b; }
func helper() -> int { return 1; } // private: only this file
- Only
publicdeclarations can be imported. Everything else is private. - A module only sees its own names and what it imports. Modules may import each other.
- Names starting with
stdare reserved for the standard library.
Parallel loops
parallel for i in 0 .. n {
result[i] = heavy(data[i]);
}
parallel for spreads the iterations over the processor's cores. Because iterations run at the same time, no two of them may write the same data. The compiler checks this and refuses unsafe loops:
- No assignment to variables declared outside the loop (
total = total + iis rejected; write into an array and usesum). - Outside arrays, lists and matrices may only be written at the loop index:
a[i] = …,m[i, j] = …. - An array written in the loop may only be read at the same index.
- No
push,pop,free,breakorreturn.continueis fine. - Called functions must not change shared data.
These rules are strict and reject some loops that would be safe. In return, every parallel loop that compiles gives the correct result. print is allowed inside, but the order of lines is not defined.
Tasks
A task runs a function at the same time as the rest of your program. Start one with marl, collect its result with await.
func part(int64 from_, int64 to) -> int64 {
int64 s = 0;
for i in from_ .. to { s = s + i % 7; }
return s;
}
func log_start(string msg) -> void {
print(msg);
}
func main() -> void {
marl log_start("started"); // start and forget
list<task<int64>> jobs = [];
for k in 0 .. 8 {
push(jobs, marl part(k * 1000000, (k + 1) * 1000000));
}
int64 total = 0;
for j in jobs {
total = total + await(j); // wait for each result
}
free(jobs);
print("total:", total);
}
marl f(a, b)startsfin a new task and immediately returns atask<T>, whereTis the return type off.await(t)waits for it; a task can be awaited more than once.- When
mainreturns, the program ends. Tasks that are still running are not waited for. - Arguments are copied, including lists, arrays and matrices. A task never shares a list with the code that started it.
- Tasks cannot write to global variables; results come back only through
await. Reading globals is fine. - A function that contains
awaitcannot be used as a value, andmarl/awaitare not allowed insideparallel for. - Results can be numbers,
bool,char, strings, functions,voidor lists of these.
Under the hood, tasks are MLIR async operations on a thread pool. Functions that wait are compiled as coroutines, so waiting frees the thread for other work: far more tasks than threads can be waiting at the same time without deadlock.
Very short tasks may appear to run one after another: starting a task takes longer than printing ten numbers. Give each task real work (or a sleep) and you will see their output interleave. Each print line is always written whole.
GPU functions
Mark a function with @GPU to run it on the graphics card. Unmarked functions are @CPU.
@GPU
func relax(matrix<float64> a, matrix<float64> b, int n) -> matrix<float64> {
matrix<float64> x = a;
for k in 0 .. n { // this loop runs on the CPU and launches GPU work
x = x * 0.5 + b; // this runs on the GPU
}
return x;
}
func main() -> void {
matrix<float64> a = ones(2000, 2000);
matrix<float64> b = ones(2000, 2000);
matrix<float64> c = relax(a, b, 100); // a and b go to the GPU, the result comes back
print(sum(c));
}
- Matrices live in GPU memory. Parameters are copied to the GPU when a CPU function calls a GPU function, and the result is copied back. GPU-to-GPU calls copy nothing.
parallel forand matrix operations become GPU kernels. Each iteration of aparallel foris one GPU thread.matmuluses its own tiled kernel with shared memory, much faster than a hand-written loop.- Inside
@GPU: numbers,bool,charand matrices only; only@GPUfunctions may be called; matrix elements are accessed insideparallel for. - Compile with
--gpu=offto run the same program entirely on the CPU, with bounds checks. Use--emit=gpu-mlirto see the kernels.
Every @GPU function is compiled twice: for the GPU and for the CPU. At run time the program picks CUDA on NVIDIA cards (when the CUDA Toolkit was present at compile time), Vulkan on other GPUs (Metal through MoltenVK on Macs, where the library is placed next to the program) and the CPU copy when there is no usable GPU. MARLIN_GPU=cpu|cuda|vulkan forces a choice and MARLIN_GPU_REPORT=1 prints the one that was made. Copies between CPU and GPU have a cost: the GPU wins on large matrices and on many operations while data stays on the device. macOS GPU support is new and has had little testing.
Standard library
Standard modules are imported like any other module. Each one is written in Marlin on top of a small C core.
import std.string as strs;
import std.file as file;
func main() -> void {
file.write_text("numbers.txt", "3,5,8\n");
list<string> parts = strs.split(strs.trim(file.read_text("numbers.txt")), ",");
int64 total = 0;
for p in parts {
total = total + to_int(p);
}
print(total, strs.join(parts, "+")); // 16 3+5+8
free(parts);
}
| Module | Contents |
|---|---|
std.string | substr find find_from contains starts_with ends_with replace trim upper lower split join |
std.file | exists read_text write_text append_text read_lines remove; line by line: open read_line eof write close |
std.io | read_line() eof() write(text) |
std.os | args() env(name) exit(code) |
std.time | now() sleep(seconds) |
std.random | seed random rand_int rand_matrix |
std.math | PI E |
std.http | HTTP/1.1 server with keep-alive (below) |
std.http.client | HTTP and HTTPS client: get post request request_timeout header; the answer is a Reply with ok error status headers body |
std.json | JSON parser: parse get has at length keys kind as_string as_int as_float as_bool quote free; the built-ins from_json and to_json convert straight to and from structs, lists and maps |
std.postgres std.sqlite | Database drivers (below) |
HTTP server
import std.http as http;
func home(http.Request req) -> http.Response {
return http.html(200, "<h1>hello</h1>\n");
}
func greet(http.Request req) -> http.Response {
return http.text(200, "hello " + http.query_param(req, "name") + "\n"); // /greet?name=Ada
}
func save(http.Request req) -> http.Response {
http.Response res = http.json(201, "{\"length\": " + str(len(req.body)) + "}");
http.add_header(res, "X-Server", "marlin");
return res;
}
func main() -> void {
list<http.Route> routes = [];
push(routes, http.get("/", home));
push(routes, http.get("/greet", greet));
push(routes, http.post("/save", save));
push(routes, http.get("/files/*", (http.Request req) -> http.Response | http.text(200, req.path)));
http.serve(8080, routes); // never returns; stop with Ctrl+C
}
- Requests carry
method,path,query,headersandbody. Helpers:header,query_param,form_value,url_decode. - Responses are built with
text,html,json,responseandredirect.Content-LengthandConnectionare added for you. - Routing: the first route matching path and method wins;
/files/*is a prefix. Wrong method gives 405, no match gives 404. - Invalid requests never reach your handler. The parser, written from RFC 9112, answers 400, 408, 413, 431, 501 or 505 by itself.
- Concurrency:
http.serve_concurrent(port, routes)handles each request in its own task, so a slow handler does not hold up the others. - For your own loop:
listen,next,dispatch,respond,handle,close.
Connections are kept alive between requests and chunked request bodies are decoded for you. The server does not speak HTTPS itself yet: put nginx or Caddy in front of it. To call other services use std.http.client, and std.json to read and write JSON (see Standard library).
Databases
std.postgres (built on libpq) and std.sqlite share the same API. Only opening the connection differs.
import std.postgres as pg;
struct User {
int64 id;
string name;
int age;
}
func main() -> void {
pg.Conn db = pg.connect("host=localhost dbname=app user=marlin password=secret");
if !db.ok { print(db.error); return; }
pg.Result r = pg.query(db, "INSERT INTO users (name, age) VALUES ($1, $2) RETURNING id",
[pg.arg_text("Ada"), pg.arg_int(36)]);
if !r.ok { print(r.error, r.code); } // code: SQLSTATE, e.g. 23505
else { print("new id:", pg.get_int(r, 0, 0)); }
pg.clear(r);
r = pg.query(db, "SELECT id, name, age FROM users WHERE age > $1", [pg.arg_int(18)]);
list<User> users = scan(r.rows); // one User per row
pg.clear(r);
for u in users { print(u.id, u.name, u.age); }
free(users);
pg.close(db);
}
- Parameters are passed as
$1,$2, … and never pasted into the SQL text, so there is no SQL injection. Usearg_null()for NULL. scan(r.rows)fills struct fields whose names match the column names.list<User> l = scan(…)takes every row,User u = scan(…)the first one.- Errors do not stop the program: check
db.ok,r.ok,r.errorandr.code. - Transactions:
begin,commit,rollback. - Pools: keep a global
pg.Pooland callpg.pool_queryfrom tasks orserve_concurrenthandlers to run queries in parallel. - SQLite:
sql.open("data.db"), or":memory:"for a temporary database. SQLite is built into Marlin; nothing to install. - PostgreSQL needs the client library libpq on the machine that runs the program (
sudo apt install libpq5,brew install libpq; on Windows it is copied next to your program). Without it,connectfails with an error that says how to install it.MARLIN_PQ_LIBRARYcan point to a specific copy.
Built-in functions
Built-ins need no import.
| Area | Functions |
|---|---|
| Output | print(a, b, …): values separated by spaces, followed by a newline |
| Time | time(): seconds as float64, for measuring durations |
| Maths | sqrt sin cos tan atan atan2 tanh exp log log2 log10 pow |
| Rounding | floor ceil round trunc |
| Other | abs min max |
| Lists | push pop free len |
| Strings | str to_int to_float len |
| Matrices | zeros ones fill sum shape; 2-D only: matmul transpose rows cols |
| Tasks | await |
| Databases | scan |
float64 start = time();
matrix<float64> c = matmul(a, b);
print("took", time() - start, "seconds");
Status & roadmap
Marlin is developed as part of a PhD thesis on compiling one code base for both CPUs and GPUs. Today it produces native executables for Linux (x86-64, ARM64), macOS (Apple silicon, Intel) and Windows (x64), with GPU kernels for CUDA and Vulkan and an automatic fallback to the CPU.
Not there yet
- Struct and matrix results from tasks; tasks in struct fields
- Slicing (
m[0]as a row), reductions along an axis, matrix comparison - Global lists and matrices, nested lists, struct arrays
- HTTPS in the HTTP server
- Hover, go-to-definition and completion in the editor
- Compiler messages in English
- Cross-compiling for another platform than the one you are on