Documentation

The Marlin guide

Everything you need to write Marlin programs, from your first print to tasks, GPU functions and web servers.

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, marl tasks and @GPU functions 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

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.

SystemSupportedYou also need
Linuxx86-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
macOSApple silicon and Intelthe Command Line Tools: xcode-select --install
Windows10 and 11, x64Visual Studio Build Tools with the Desktop development with C++ workload and the C++ Clang tools component (the command is below)
Windows: Build Tools with Clang
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. @GPU code 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 libpq5 on Linux, brew install libpq on 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.

terminal
$ 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

hello.marl

func main() -> void {
    int total = 0;
    for i in 0 .. 5 {
        total = total + i;
    }
    print("total =", total);
    print("root:", sqrt(total));
}
terminal
$ 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:

terminal
# 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:

terminal
$ 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
OptionWhat it does
-o fileName of the output file
-O0 … -O3Optimisation level. The default is -O0.
--emit=…Stop at an intermediate stage (see below)
-I dirExtra directory to search for imported modules (can be repeated)
--gpu=autoThe 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=onLike auto, but warns when the CUDA toolkit is missing, so NVIDIA cards would use Vulkan.
--gpu=offCompile @GPU functions for the CPU only. Useful for comparing results.
--print-astPrint the syntax tree
--lspRun as a language server (used by the editor extension)
--versionPrint the compiler version
doctorCheck 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.

ValueShows
mlirHigh-level MLIR (scf, tensor, async …)
gpu-mlirMLIR after GPU outlining: every kernel inside a gpu.module, launched with gpu.launch_func
llvm-mlirMLIR lowered to the LLVM dialect
llvm-irLLVM IR, as in .ll files
objObject file, not yet linked
exeExecutable (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.

  1. Install Marlin first (see Getting started); the extension uses the marlin command on your PATH.
  2. Download marlin-vscode.vsix from the download page.
  3. In VS Code's Extensions panel, choose … → Install from VSIX… and pick the downloaded file.
  4. Open any .marl file. Press Ctrl+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

basics.marl

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]);
}
KindTypes
Signed integersint8 int16 int (32-bit) int64
Unsigned integersuint8 uint16 uint uint64
Floating pointfloat (32-bit) float64
Othersbool char string
Collectionsarrays int[5], lists list<int>, matrices matrix<float, N>
Functions(int) -> int
Taskstask<T>
Your ownstruct Point { … }
  • Numeric types convert automatically (int h = 7.5; gives 7).
  • Conditions must be bool: write if x != 0, not if x.
  • Every program needs a main without parameters that returns void or int.

Control flow

control.marl

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

functions.marl

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

lists.marl

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>
SizeFixed in the sourceGrows and shrinks (push, pop)
Passing to functionsThe size is part of the typeAny length
Returning from functionsNoYes
Bounds checksConstant indices, at compile timeEvery 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

strings.marl

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 free needed.
  • Length and indexing count bytes. Text is UTF-8, so non-ASCII letters take more than one byte.
  • + joins two strings only; convert numbers with str(...).
  • to_int and to_float stop the program with a message if the text is not a number.

Matrices

matrices.marl

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> is matrix<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 as a + b * 2 - c is fused into a single loop with no temporaries, so writing it as one expression is fastest.
  • On the CPU, matmul is spread across all cores.
  • Shapes and indices are checked at run time with a clear message.

Structs

structs.marl

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:

commands.marl

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. Write l[0].x = 9 to change the element in place.
  • A struct whose fields are numbers, bool, char or strings can be a global (declared without a value, assigned in functions).
  • Mark a struct public to use it from other modules.

Modules

Every .marl file is a module. Imports go at the top of the file.

main.marl

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));
}
tools/vector.marl

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 public declarations 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 std are reserved for the standard library.

Parallel loops

parallel.marl

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 + i is rejected; write into an array and use sum).
  • 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, break or return. continue is 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.

tasks.marl

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) starts f in a new task and immediately returns a task<T>, where T is the return type of f. await(t) waits for it; a task can be awaited more than once.
  • When main returns, 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 await cannot be used as a value, and marl / await are not allowed inside parallel for.
  • Results can be numbers, bool, char, strings, functions, void or 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.marl

@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 for and matrix operations become GPU kernels. Each iteration of a parallel for is one GPU thread.
  • matmul uses its own tiled kernel with shared memory, much faster than a hand-written loop.
  • Inside @GPU: numbers, bool, char and matrices only; only @GPU functions may be called; matrix elements are accessed inside parallel for.
  • Compile with --gpu=off to run the same program entirely on the CPU, with bounds checks. Use --emit=gpu-mlir to 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.

numbers.marl

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);
}
ModuleContents
std.stringsubstr find find_from contains starts_with ends_with replace trim upper lower split join
std.fileexists read_text write_text append_text read_lines remove; line by line: open read_line eof write close
std.ioread_line() eof() write(text)
std.osargs() env(name) exit(code)
std.timenow() sleep(seconds)
std.randomseed random rand_int rand_matrix
std.mathPI E
std.httpHTTP/1.1 server with keep-alive (below)
std.http.clientHTTP and HTTPS client: get post request request_timeout header; the answer is a Reply with ok error status headers body
std.jsonJSON 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.sqliteDatabase drivers (below)

HTTP server

server.marl

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, headers and body. Helpers: header, query_param, form_value, url_decode.
  • Responses are built with text, html, json, response and redirect. Content-Length and Connection are 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.

users.marl

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. Use arg_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.error and r.code.
  • Transactions: begin, commit, rollback.
  • Pools: keep a global pg.Pool and call pg.pool_query from tasks or serve_concurrent handlers 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, connect fails with an error that says how to install it. MARLIN_PQ_LIBRARY can point to a specific copy.

Built-in functions

Built-ins need no import.

AreaFunctions
Outputprint(a, b, …): values separated by spaces, followed by a newline
Timetime(): seconds as float64, for measuring durations
Mathssqrt sin cos tan atan atan2 tanh exp log log2 log10 pow
Roundingfloor ceil round trunc
Otherabs min max
Listspush pop free len
Stringsstr to_int to_float len
Matriceszeros ones fill sum shape; 2-D only: matmul transpose rows cols
Tasksawait
Databasesscan
timing.marl

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