Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance Considerations

Best practices and optimization tips for writing efficient Suji code.

How Suji Executes Your Code

Knowing the execution model tells you which optimisations are worth attempting:

  • Tree-walking AST interpreter. Every expression is re-walked each time it is evaluated, so per-operation constant factors are high compared with a compiled language.
  • No JIT, no parallelism, no lazy sequences, no tail-call optimisation.

The practical consequence: algorithmic choices — how many times you touch each element, how much you allocate — dominate. Micro-tuning individual expressions rarely pays off. Measure before and after any change.

Measuring

There are two timing primitives: os:uptime_ms() and time:now():epoch_ms. Both are millisecond-resolution, so a single fast operation will measure as 0 — repeat the work enough times to get a signal.

import std:println
import std:os

benchmark = |work| {
    start = os:uptime_ms()
    work()
    return os:uptime_ms() - start
}

elapsed = benchmark(|| (1..=100000)::sum())

println(elapsed::is_number())    # true
println(elapsed >= 0)            # true

time:now() returns a map, so subtract the epoch_ms field rather than the maps themselves:

import std:println
import std:time

start = time:now():epoch_ms
total = (1..=1000)::sum()
elapsed = time:now():epoch_ms - start

println(elapsed >= 0)    # true

Compare two implementations by running both and reporting the ratio at runtime. Do not trust numbers written into documentation, including this page — measure on your own machine and your own data.

Everything Is Eager

map, filter and fold allocate

List methods are not lazy. Each stage walks the whole list and builds a brand-new list, so a three-stage chain over a million elements allocates three million-element lists.

import std:println

data = 1..=10

# Three passes, two intermediate lists
result = data
  ::filter(|x| x > 2)
  ::map(|x| x * 2)
  ::fold(0, |acc, x| acc + x)

println(result)    # 104

That chain is perfectly good style for small and medium lists — clarity is worth an allocation. When the list is large, collapse the passes into one:

import std:println

data = 1..=10

# One pass, no intermediate lists
result = data::fold(0, |acc, x| match {
    x > 2 => acc + x * 2,
    _ => acc,
})

println(result)    # 104

Filtering before mapping is also worth doing when the filter is selective, since it shrinks the input to the more expensive stage:

import std:println

data = 1..=10

cheap_first = data::filter(|x| x % 2 == 0)::map(|x| x * x)
println(cheap_first::sum())    # 220

Ranges materialise complete lists

a..b and a..=b are not lazy iterators — they build the whole list immediately. 0..1000000 allocates a million elements before the loop body runs even once.

import std:println

r = 0..1000
println(r::is_list())     # true
println(r::length())      # 1000

For a large counted loop where you do not need the list, use an explicit counter:

import std:println

i = 0
total = 0
loop {
    i >= 1000 && break
    total = total + i
    i++
}

println(total)    # 499500

For moderate sizes, loop through 0..n with i is clearer and the allocation is not worth worrying about.

Strings Are Character-Indexed

Strings are UTF-8, and length() and indexing count characters, not bytes. That means indexing is a scan from the start of the string, so indexing in a loop is quadratic.

import std:println

s = "hello world"

println(s::length())    # 11
println(s[0])           # h
println(s[6;])          # world

Convert once instead of indexing repeatedly:

import std:println

s = "hello"

# Good: one conversion, then cheap list access
chars = s::to_list()
loop through chars with c {
    println(c)
}
# h
# e
# l
# l
# o

Concatenation in a loop

Each + builds a new string. Collect the pieces and join once:

import std:println

items = ["a", "b", "c"]

# Slower: a new string per iteration
result = ""
loop through items with item {
    result = result + item
}
println(result)    # abc

# Faster: one allocation at the end
println(items::join(""))    # abc

Interpolation is a single build step, so prefer it over a chain of +:

import std:println

name = "Ada"
count = 3

println("Hello ${name}, you have ${count} messages")
# Hello Ada, you have 3 messages

Recursion Has a Hard Limit

There is no tail-call optimisation. Every call consumes a native stack frame, and a few hundred frames deep the interpreter aborts with a stack overflow that you cannot catch. The ceiling depends on the function: a simple 1 + f(n - 1) recursion survives 600 levels but not 700, and an accumulator-passing version survives 700 but not 900. Recursion is fine for shallow, tree-shaped work; it is not a substitute for a loop.

import std:println

# Fine: depth is bounded and small
depth = |n| match {
    n == 0 => 0,
    _ => 1 + depth(n - 1),
}

println(depth(500))    # 500

Rewrite deep recursion as iteration:

import std:println

# Recursive sum would overflow for large n
total = 0
loop through 1..=10000 with i {
    total = total + i
}

println(total)    # 50005000

Memoise repeated subproblems

A map makes exponential recursion linear, and keeps the depth low enough to be safe:

import std:println

memo = {}

fib = |n| {
    memo::contains(n) && return memo::get(n)
    value = match {
        n < 2 => n,
        _ => fib(n - 1) + fib(n - 2),
    }
    memo[n] = value
    return value
}

println(fib(30))    # 832040

Note that memo is a captured variable, not a parameter — that matters, as the next section explains.

Arguments Are Copied

Maps and lists are passed by value. Handing a large structure to a function copies it, and mutations inside the function are lost. Variables captured from an enclosing scope are shared instead.

import std:println

m = { a: 1 }

by_param = |target| { target["b"] = 2 }
by_param(m)
println(m::contains("b"))    # false

by_capture = || { m["c"] = 3 }
by_capture()
println(m::contains("c"))    # true

So for a hot loop over a big structure, prefer a closure over the structure, or pass only the slice you need — not the whole document.

Streams Read Eagerly

read_all() and read_lines() load the entire file into memory. They are convenient and fine for configuration files and modest data, but they are not streaming.

import std:println
import std:io

path = `mktemp`
f = io:open(path, true, true)
loop through 1..=5 with i {
    f::write("line ${i}\n")
}
f::close()

g = io:open(path)
lines = g::read_lines()      # whole file in memory
g::close()

println(lines::length())     # 5
println(lines[0])            # line 1

read_line() is the one incremental reader: it returns the next line without its newline, and nil at end of file. Use it when the file may be larger than memory.

import std:println
import std:io
import std:os

path = `mktemp`
f = io:open(path, true, true)
loop through 1..=1000 with i {
    f::write("row ${i}\n")
}
f::close()

# Constant memory: one line at a time
g = io:open(path)
matching = 0
loop {
    line = g::read_line()
    line == nil && break
    match { line ~ /^row 1[0-9][0-9]$/ => { matching++ } }
}
g::close()
os:rm(path)

println(matching)    # 100

Batch your writes

Each write() is a syscall. Build the text and write once when you can:

import std:println
import std:io
import std:os

path = `mktemp`
items = [1, 2, 3, 4, 5]

content = items::map(|i| i::to_string())::join("\n")
out = io:open(path, true, true)
out::write(content)
out::close()

check = io:open(path)
println(check::read_all())
check::close()
os:rm(path)
# 1
# 2
# 3
# 4
# 5

Choosing Data Structures

  • Lists are positional. xs[i] is a direct index, but contains() and index_of() scan linearly — inside a loop that becomes quadratic. Build a map when you need repeated membership tests.
  • Maps are keyed lookups and keep insertion order. Prefer them for “have I seen this?” questions.
  • Tuples are fixed-size and cannot be indexed; they are for returning a couple of values, not for storage.
import std:println

names = ["ann", "bo", "cy"]

# Slow inside a loop: linear scan each time
println(names::contains("bo"))    # true

# Faster for repeated tests: build a lookup map once
seen = {}
loop through names with n {
    seen[n] = true
}

println(seen::contains("bo"))     # true
println(seen::contains("zed"))    # false

Hoist Work Out of Loops

Anything that does not depend on the loop variable belongs above the loop:

import std:println

items = ["Ada", "Bo", "Cy"]
prefix = "user:"::upper()

out = []
loop through items with item {
    out::push(prefix + item::upper())
}

println(out::join(","))    # USER:ADA,USER:BO,USER:CY

Early exit avoids the rest of the work entirely:

import std:println

find_first = |xs, predicate| {
    loop through xs with item {
        predicate(item) && return item
    }
    return nil
}

println(find_first([1, 5, 9], |x| x > 4))     # 5
println(find_first([1, 2], |x| x > 100))      # nil

Performance Checklist

DO:

  • Measure with os:uptime_ms() before and after a change
  • Collapse multi-stage list chains into one pass when lists are large
  • Collect strings and join once instead of concatenating in a loop
  • Convert a string to a list once rather than indexing it repeatedly
  • Use a map for repeated membership tests
  • Use read_line() when a file may not fit in memory
  • Memoise expensive recursive computations
  • Batch writes into a single write()
  • Exit loops early with break or return

DON’T:

  • Expect map/filter/fold or ranges to be lazy — they are not
  • Materialise a huge range just to count
  • Recurse deeply; there is no tail-call optimisation
  • Pass large maps and lists as arguments in hot code — they are copied
  • Assume read_lines() streams; it loads the whole file
  • Optimise before measuring

When to Optimize

  1. Measure first: don’t guess, time it
  2. Find the real bottleneck: usually I/O or an accidental quadratic loop
  3. Fix the algorithm: constant-factor tweaks rarely matter in an interpreter
  4. Keep it readable: an eager three-stage chain is fine until it isn’t
  5. Re-measure: confirm the change actually helped

See Also