Turmeric Performance Guide

Turmeric compiles to optimised C99 (-O2 by default, -O3 with --release). This means its performance ceiling is close to hand-written C, but the patterns you choose matter. This guide covers the major performance dimensions -- numerical computation, data structures, string processing, concurrency, memory and GC, recursion, and I/O -- and finishes with a methodology section for writing and interpreting benchmarks.


Build flags

Always benchmark release builds:

tur run release          # optimised tur in build-release/
build-release/tur build prog.tur -o prog

The debug build (tur run build, build/tur) inserts contract checks and disables optimisations; its timing numbers are not meaningful for comparison.

That is the build of the compiler. The program it compiles is built at -O2 by tur build and tur run, and at -O3 by tur build --release and tur run --release. TUR_CC_FLAGS replaces either. -march=native is never on by default, because its binaries need the machine that built them.


Numerical computation

Integer and floating-point arithmetic

Arithmetic on int and float compiles to the corresponding C types. There is no boxing overhead for scalars declared with concrete types:

(defn square [x] : int (* x x))
(defn hyp [a b] : float
  (sqrt (+ (* a a) (* b b))))

Avoid leaving numeric expressions untyped in hot loops -- the elaborator may widen to a tagged value when it cannot infer a concrete numeric type.

Fibonacci (iterative vs recursive)

Iterative is faster for large N because it avoids stack growth:

; iterative -- O(n) time, O(1) space.  The named-let `loop` call is a
; self-tail-call, so it is lowered to an iterative backedge (see
; "Self-tail-call optimization" below): the stack does not grow with n.
(defn fib-iter [n] : int
  (let loop [i n a 0 b 1]
    (if (= i 0)
      a
      (loop (- i 1) b (+ a b)))))

; recursive -- O(2^n) time, avoid for n > ~30
(defn fib-rec [n] : int
  (if (< n 2)
    n
    (+ (fib-rec (- n 1)) (fib-rec (- n 2)))))

Self-tail-call optimization

A self-tail-call -- a call to the enclosing function (or named-let loop binding) in tail position -- is lowered to an iterative loop rather than a C function call. The compiler reassigns the parameter variables and jumps back to the top of the function body, so iteration count no longer drives C-stack depth: a self-recursive countdown of 10,000,000 iterations completes instead of overflowing the stack.

The guarantee applies to:

with tail position computed through if, match arms (guarded ones included), cond/when (which macro-expand to if), do, let/letrec, and the LAST operand of and / or -- (and (>= n 0) (all-ok? (- n 1))) is if (!(n >= 0)) return false; and a backedge (tests/fixtures/tailcall-and-or-deep). For example, both of these are lowered to a loop:

; self-recursive defn -- tail call in the `if` else-branch
(defn count-down [n :int acc :int] :int
  (if (= n 0)
    acc
    (count-down (- n 1) (+ acc 1))))

; named-let -- the (loop ...) call is the self-tail-call
(defn sum-to [n :int] :int
  (let loop [i   :int 0
             acc :int 0]
    (if (>= i n)
      acc
      (loop (+ i 1) (+ acc i)))))

A named let lowers one of two ways -- to a lifted closure when the loop body reads an enclosing variable, and to a plain lifted function when it does not -- and both are optimized. In the closure case the backedge reassigns the loop parameters and leaves the captured environment alone (it does not change across a self-call). Pinned by tests/fixtures/tco-named-let-capture-deep and tests/fixtures/tco-named-let-nocapture-deep at 5,000,000 iterations each.

Mutual tail calls. Functions that tail-call each other in a cycle are fused into one C function with a dispatch loop, so the cycle runs in constant stack at any optimization level:

(defn is-even? [n :int] :bool (if (= n 0) true  ^tailcall (is-odd?  (- n 1))))
(defn is-odd?  [n :int] :bool (if (= n 0) false ^tailcall (is-even? (- n 1))))

Each member keeps its own C function as a thin wrapper, so calls from outside the cycle -- and non-tail calls from inside it -- are unchanged. A group forms when every member is a plain top-level defn (no closure, dictionary, inline-C, effectful or catch-unwind body; no variadic, fn-typed or pass-by-pointer parameter), all share one C return type, and the group stays within 8 members and 16 parameters in all. Pinned by tests/fixtures/tailcall-mutual-deep at 10,000,000 steps at -O0. Before this, a small cycle passed at -O2 only because clang inlined it into a loop, and overflowed at -O0.

A cycle T5 does not fuse -- more than 8 members or 16 parameters -- still has its calls in C tail position (return f(args);), and where the C compiler can guarantee a tail call they are marked musttail too, so under clang on x86-64/aarch64 such a cycle also runs in constant stack at -O0 (tests/fixtures/tailcall-musttail-deep). gcc and the JIT have no such guarantee, so on those the calls are ordinary and ^tailcall still refuses them: the annotation promises a tail call on every toolchain.

Boundary (1.0). Self and mutual tail calls are optimized. The following are left as ordinary recursive calls -- correct, but not stack-optimized:

Asking to be told: ^tailcall

The boundary above is a list you have to hold in your head, and nothing in the source says which side a given call landed on. ^tailcall annotates a call with the assertion this must become a real tail call; a call the compiler cannot place in tail position is then TUR-E0716 at compile time, naming the reason, instead of a stack overflow at an unpredictable depth later.

(defn count-down [n :int acc :int] :int
  (if (= n 0)
    acc
    ^tailcall (count-down (- n 1) (+ acc 1))))   ; ok -- becomes a backedge

(defn not-tail [n :int] :int
  (if (= n 0)
    0
    (+ 1 ^tailcall (not-tail (- n 1)))))          ; TUR-E0716: argument position

The annotation is a prefix -- it binds to the call that follows it -- so it fits in a match or handle arm, where an extra list element would silently re-pair every clause after it. The explicit (^tailcall (f x)) spelling means exactly the same thing, and is what tur fmt writes.

It changes nothing about how the call runs. Removing it always silences the error; it does not make the call a tail call, it only stops asking.

Every reason the boundary list gives has its own message. tur explain TUR-E0716 prints them all, with what to do about each:

$ tur run --debug loop.tur
loop.tur:9:24: error [TUR-E0716]: `^tailcall` call is not in tail position: an
owned local of the enclosing `let` (a `ref<T>`, `rc<T>` or other value with drop
glue) is still live at the call -- it is passed, captured, borrowed or read as
more than a plain number -- so its drop cannot move ahead of the call

Two things worth knowing about the check itself:

The annotation is also the test instrument for the rest of the tail-call work: each later stage is verified by a fixture that annotates a call and asserts the annotation holds at -O0. A tail-call fixture built at -O2 asserts nothing -- clang will inline a small recursive cycle into a loop and the fixture then measures the C compiler. See proper-tail-calls-plan.md.

Dynamic tail calls in Saffron. A call through an any in tail position -- (f f (- n 1)) where f is a function value -- runs in constant stack too. There is no callee to branch to, so it bounces instead: the call is recorded and handed back to a trampoline loop one frame below, which makes it. Every Saffron function takes any arguments and returns an any, which is what lets one loop make any such call. Measured 2.7x faster than the nested calls it replaced (benchmarks/saffron-dyn-tail-results.md), because each nested call used to open and tear down a delimited-control entry. Pinned at 10,000,000 deep at -O0 by tests/fixtures/tailcall-dyn-deep; ^tailcall accepts a dynamic call and refuses one only for its position.

Indirect tail calls in typed Turmeric (through a fn-typed value) are not built and are not planned: without one calling convention for every function there is nothing uniform for a trampoline to call. Routing them through the existing CPS backend is not a way out either: that backend emits a tail call as an ordinary call, a panic check, and a continuation invocation, which was measured rather than assumed. See proper-tail-calls-plan.md for the measurements and the staging, and control-flow-completeness-plan.md (Phase CF1) for the self-tail-call work that shipped.

Prime sieve

The Sieve of Eratosthenes benefits from a vec (a growable array over a malloced block) rather than a linked list.

Corrected on 2026-08-20: this example previously used vec/make, vec/get, vec/set! and (import "stdlib/vec.tur"), none of which exist. The version below was run: it prints 25, the number of primes below 100.

(load "stdlib/vec.tur")

(defn sieve-count [limit : int] : int
  (let [flags   (:: (vec-new) (Vec int))
        ^mut k  0
        ^mut i  2
        ^mut n  0]
    (while (<= k limit) (vec-push! flags 1) (set! k (+ k 1)))
    (vec-set! flags 0 0)
    (vec-set! flags 1 0)
    (while (<= (* i i) limit)
      (when (= (vec-get flags i) 1)
        (let [^mut j {i * i}]
          (while (<= j limit)
            (vec-set! flags j 0)
            (set! j (+ j i)))))
      (set! i (+ i 1)))
    (set! k 2)
    (while (<= k limit)
      (when (= (vec-get flags k) 1) (set! n (+ n 1)))
      (set! k (+ k 1)))
    n))

Note there is no numeric-range for: for is the monadic comprehension, so a counted loop is a while over a ^mut binding. A binding that set! writes to must be declared ^mut at its binding site.

Monte Carlo pi estimation

Use the stdlib RNG rather than reaching into <stdlib.h> from inline-C, so the compiler can see through the calls.

Corrected on 2026-08-20: this example previously loaded stdlib/rand.tur and called rand/float. The module is stdlib/random.tur and the function is rand-float, which returns an int in [0, 9999] -- divide by 10000.0 for a float in [0, 1). int->float lives in stdlib/math.tur and is not auto-loaded. The version below was run and lands in range.

(load "stdlib/math.tur")
(load "stdlib/random.tur")

(defn estimate-pi [samples : int] : float
  (let [^mut i      samples
        ^mut inside 0]
    (while (> i 0)
      (let [x (/ (int->float (rand-float)) 10000.0)
            y (/ (int->float (rand-float)) 10000.0)]
        (when (<= (+ (* x x) (* y y)) 1.0) (set! inside (+ inside 1))))
      (set! i (- i 1)))
    (* 4.0 (/ (int->float inside) (int->float samples)))))

Note rand-float is seeded from time(NULL) on first use, so successive runs differ -- do not pin an exact value in a test.

Data structures

The API names in this section were corrected on 2026-08-20. It previously documented vec/make, vec/sort!, vec/fill!, hamt/insert and hamt/get-or, none of which exist.

Lists vs vecs

Use list / cons for functional transformations where sharing matters; use vec for index-heavy access and mutation in place.

Operation list vec
Prepend O(1) O(n)
Random read O(n) O(1)
Append (single) O(n) O(1) amortised

The real vec surface is vec-new, vec-push!, vec-get, vec-set!, vec-len (stdlib/vec.tur), plus the [...] literal, which lowers to vec-of in expression position. See data-literals-guide.md.

Hash maps

stdlib/hamt.tur (a persistent hash-array-mapped trie) is the standard map, wrapped by stdlib/map.tur. The operations are hamt/set and hamt/get -- persistent, so each set returns a new map sharing structure with the old. #map{...} literals construct one directly.

For a key that must outlive its source buffer, use String rather than a computed cstr: MapKey for String copies and owns the key bytes, where a cstr key can dangle. See strings-guide.md.

Sorting

No vec/sort! ships today. Sort by moving through a list or writing the comparison loop directly over the vec; if you add a sort to stdlib/vec.tur, this section should name it.

String and text processing

Corrected on 2026-08-20. This section previously documented str/concat, str/builder, str/view, str/format and stdlib/regex.tur. None of them exist.

Turmeric has three string-shaped types and the choice between them is the performance decision that matters. cstr is a borrowed const char * with no length; str (stdlib/str.tur) is a borrowed pointer+length view, so substring-without-copy over a buffer you already own; String (stdlib/string.tur) owns refcounted immutable bytes, so Clone is an O(1) retain and structural sharing in a persistent map is free.

Repeated concatenation is the usual hot spot: each concat allocates and copies, so building a string in a loop is quadratic. Prefer accumulating pieces and joining once, and prefer a str view over copying a substring out.

There is no regex engine in the tree. stdlib/cstr.tur provides the byte primitives (cstr-len, cstr-nth, cstr-sub, cstr-eq?) that scanning code is written against.

See strings-guide.md for the full comparison.

Concurrency and parallelism

Corrected on 2026-08-20. This section previously documented stdlib/concurrency.tur and stdlib/dynamic-vars.tur; neither exists. The dynamic-variable module is stdlib/dynvar.tur.

Threads and channels live in stdlib/concurrent.tur (mutexes, rwlocks, condvars, and the mutex-guard-* pair behind the with-lock / with-read-lock / with-write-lock macros). stdlib/schan.tur provides session-typed channels, where the protocol is a phantom threaded through each operation, so a send that must precede a receive is checked at compile time. stdlib/stm.tur and stdlib/stm-sync.tur cover transactional variables and TMVar/TChan.

Thread-local and dynamically-scoped state is stdlib/dynvar.tur (defdynamic / let-dyn).

The costs worth knowing: a thread is an OS thread, so creation is not free and a work-queue over a fixed pool beats spawning per item; an uncontended with-lock is cheap while a contended one parks; and STM retries the whole transaction on conflict, so keeping transactions short matters more than keeping them few.

See threading-guide.md and stm-guide.md.

Memory and allocation

Corrected on 2026-08-20, and again on 2026-10-14. The GC-pressure scripts this section once referenced do exist, but in the benchmarks repo under performance-comparison/, not at the top level: performance-comparison/scripts/run_all.sh memory runs the cross-language alloc_churn benchmark, and analyze_results.py sits beside it (the 2026-08-20 note said neither existed). That suite compares Turmeric with other languages -- see Cross-language comparison; for timing a Turmeric change against itself, see Benchmarking methodology.

Plain Turmeric has no always-on tracing collector. Memory is managed in layers -- the stack and plain malloc/free with compiler-emitted drops, bump arenas, reference counting for rc<T>, and a Bacon-Rajan cycle collector on top of RC that is off by default -- and a :heap node is reclaimed by a region when a with-region or bt-scope bracket can prove nothing escapes it. The gc guide's The layers, at a glance and What is not GC-managed (regions included) have the details. So the cost model is allocation, retain/release traffic and drop glue rather than collection pauses. The exception is the two dynamic dialects: a compiled #lang r7rs or #lang saffron program runs under a conservative mark-sweep collector that does stop to collect (The dialect collector; TUR_GC_STATS=1 prints what it did). Practical consequences for plain Turmeric:

For measured per-feature costs (bytes per Vec slot, Map entry, rc, closure, effect) and strategies for using less, see memory-usage-guide.md.

See gc-guide.md for how RC, arenas, and the cycle collector fit together, and ownership-guide.md for deciding who owns what in the first place.

Recursion and stack usage

Corrected on 2026-08-20. This section previously pointed at stdlib/trampoline.tur, which does not exist.

Self-tail-calls are optimised into a loop -- see Self-tail-call optimization above, which is measured and accurate. A tail-recursive accumulator therefore runs in constant stack and is the idiomatic way to write a loop over a list.

Mutual tail recursion is turned into a loop when the cycle forms a group: even? tail-calling odd? tail-calling even?, both plain top-level defns with one return type, is fused into one C function with a dispatch loop and runs in constant stack at any optimization level (see Mutual tail calls under Self-tail-call optimization for what forms a group: up to 8 members and 16 parameters, no closure, dictionary, inline-C, effectful or catch-unwind member). A cycle of CPS-lowered functions is fused the same way on the CPS backend. A cycle outside a group keeps its calls in C tail position, which is guaranteed constant stack only where the C compiler honours musttail (clang on x86-64/aarch64); on gcc and the JIT it holds only if the optimizer happens to make the call a sibling call. ^tailcall on a call refuses it on every toolchain unless the cycle was fused, so it tells you at compile time which side a cycle landed on. Mutual recursion that is not in tail position always grows the stack, so a deep one needs restructuring into a single self-recursive function with an explicit state parameter, or into an explicit worklist. There is no trampoline module to reach for.

Generators (gen / yield) and the CPS-lowered paths have their own cost model. Under --interpret, the tree-walking interpreter used to retain roughly 4 KiB per trampolined step; since 2026-10-05 an activation's frames go back to a free list when nothing captured them, so an ordinary loop runs flat there too (a closure capture still keeps its frames). None of this applies to a compiled program.

I/O operations

Corrected on 2026-08-20. This section previously documented io/open and io/read-all, neither of which exists.

stdlib/io.tur provides read-file (whole file into a malloc'd, NUL-terminated buffer; NULL on error; caller frees) and write-file, plus the lower-level file handle operations. stdlib/fs.tur covers path and directory work.

The performance points that hold: one read-file beats a per-line read loop for a file that fits in memory; writes should be batched rather than issued per record; and the returned buffer is owned by the caller, so a read in a loop that never frees is a leak, not merely garbage.

Asynchronous file and socket I/O is a separate surface -- see async-await-guide.md.

Real-world algorithms

N-body simulation

Float-heavy simulations benefit from struct-of-arrays layout when possible. Turmeric structs are currently records (array-of-structs), so prefer separating coordinate vectors into dedicated vecs if profiling reveals cache pressure:

(defstruct body [x :float y :float z :float
                 vx :float vy :float vz :float
                 mass :float])

Ray tracing

Ray-box intersection and dot products are the hot paths. Annotate return types concretely (float) so the elaborator does not insert tag checks in the inner loop:

(defn dot [ax ay az bx by bz] :float
  (+ (* ax bx) (+ (* ay by) (* az bz))))

Execution engines: interpreter vs tur jit vs cc -O2

Turmeric has three execution engines, and which one is fastest depends on what you are optimizing for -- startup latency or steady-state throughput:

engine invocation compile step best for
interpreter tur --interpret f.tur none tiny scripts, REPL turns, debugging
MIR JIT tur jit f.tur in-process (c2mir) run-edit-run loops, spice REPL reloads
cc tur build f.tur + run subprocess cc -O2 long-running programs, deployment

A project can select its default engine for tur run declaratively: :engine "cc" | "jit" | "interp" in build.tur, overridden by TUR_ENGINE in the environment, overridden by --engine on the command line (the same ladder shape as :build-dir). An unknown value is a hard error (TUR-E0311), and a "jit" selection on a build without the engine, or without the jit experiment enabled, fails loudly rather than silently substituting -- the engines differ in SEMANTICS (#?(:tur ... :turi ...), inline-C carve-outs, c2mir divergences), not just speed, so pair a load-bearing :engine with a :tur-version floor (older binaries silently ignore unknown manifest keys and run under cc).

Measured triangle, regenerated 2026-10-15 by benchmarks/run-triangle.sh from benchmarks/triangle/ (the performance audit's baseline box: x86-64 Linux under WSL2, gcc 13.3, Release -DTUR_JIT=ON tur; REPS=11, two sweeps, each cell the faster one; end-to-end wall time):

program interpreter tur jit cc build cc run cc total
fib (fib 27, call-heavy) 165ms 105ms 170ms 3ms 173ms
loop-sum (5M-iteration loop) 1489ms 107ms 160ms 2ms 162ms
mandel (float inner loop) 647ms 109ms 178ms 4ms 182ms

All three legs are re-measured together on each run, so the columns are comparable to each other. They are NOT comparable to a snapshot taken on a different machine. The cc build column is the noisiest (up to 10% between sweeps). The audit's write-up, wp4-engines.md, has the method, and WP0's baseline has every corpus program on all three engines.

How to read it:

The engine triangle is exact on OUTPUT: benchmarks/run-triangle.sh refuses to time a program whose three engines disagree, and the fixture corpus runs under all three harnesses (tests/run.sh, tests/run-turi.sh, tests/run-jit.sh).

Cross-language comparison

A separate suite, performance-comparison/ in the turmeric-lang/benchmarks repo, runs the same 21 benchmarks (numerical, data structures, string processing, concurrency, memory, recursion, I/O, real-world, micro) across up to nine columns: C, Turmeric (tur build), turi (tur --interpret), tur jit, Rust, Haskell, Clojure, Racket, and Python. This is the scripts/run_all.sh / aggregate_results.py / check_environment.sh harness -- it lives in the benchmarks repo, not the top-level benchmarks/ this guide's methodology section otherwise describes, and answers a different question: not "did this Turmeric change regress," but "how does Turmeric's output compare to a native-compiled, a JVM-hosted, and a scripting-language peer doing the same work." See performance-comparison/README.md and its docs/methodology.md for the full setup, same-algorithm ground rules, and how to run it.

Two things any reading of that suite's numbers should keep in view:

Benchmarking methodology

The real harness

Benchmarks live in benchmarks/ and are run by benchmarks/run-benchmarks.sh -- all of them, or one by name:

./benchmarks/run-benchmarks.sh            # every benchmark
./benchmarks/run-benchmarks.sh bench-logic-query

The layout it expects:

File Role
benchmarks/<name>.tur the benchmark source
benchmarks/<name>-baseline.c optional monomorphic C baseline to compare against
benchmarks/<name>.time optional upper bound in milliseconds

Results are written to benchmarks/benchmark-results.md.

A benchmark does not time itself

This is the part the old template got backwards. The runner measures wall time around the built executable (measure_time in run-benchmarks.sh); the benchmark itself just does the work. So a benchmark needs no clock, no argument parsing, and no elapsed_ns= output -- it is an ordinary program with a main, and benchmarks/bench-logic-query.tur and friends are the models to copy.

If a benchmark does need to size its work from the command line, read *args* or use args/parse from stdlib/args.tur. Reading g_tur_args directly, or via a hand-rolled parse-first-arg, is forbidden -- see CLAUDE.md.

Build flags

The runner compiles with -O2 by default and honours TUR_CC_FLAGS and CC:

CC=clang TUR_CC_FLAGS="-O3 -march=native" ./benchmarks/run-benchmarks.sh

Reading a result honestly

What CI tracks, nightly

The nightly arm64 workflow's bench job (.github/workflows/nightly-arm64.yml) turns a handful of benchmarks into a trend. It builds a Release -DTUR_JIT=ON tur on a fresh macOS arm64 runner and runs the programs named in benchmarks/audit/tracked.txt -- at most ten, each running for more than about a second on some engine, so the runner's jitter is a small part of the number -- through the performance audit's corpus runner (benchmarks/audit/run-corpus.py):

benchmarks/audit/bench-rows.py writes one row per program, engine and metric to the ci-metrics branch's bench-<year>.jsonl, and /ci's Benchmarks tab plots them, one engine and metric per view.

How to read it:

To reproduce a night's numbers locally (Linux or macOS, Release tur):

python3 benchmarks/audit/run-corpus.py --tur ./build-release/tur \
    --corpus benchmarks/audit/tracked.txt --reps 3 --out /tmp/bench
python3 benchmarks/audit/bench-rows.py /tmp/bench --out /tmp/bench.jsonl

Compiler timings (--timings)

--timings answers "why is tur build slow?" -- where the compiler's time goes, as opposed to the program's. It works on any compiling command (build, check, emit-c, run, jit):

tur build --timings f.tur               # table on stderr
tur build --timings=prof.json f.tur     # table on stderr + JSON to prof.json
tur build --timings prof.json f.tur     # same; the bare form takes the next
                                        # argument only if it ends in .json
                                        # or is /dev/... (else it is an input)
tur check --timings /dev/stdout f.tur   # JSON on stdout (--timings=- too)

Each phase is a zone the driver or elaborator enters and leaves:

phase what is charged to it
read the reader, for every file: the entry, the auto-loaded stdlib, imports, (load ...)
sweet the sweet-exp preprocessor that runs before the reader
module-load finding, reading and splicing module files -- the stdlib autoload, import, (load ...) -- but not their parse (read) or their elaboration (elaborate)
macro-expand computing an expansion; elaborating the result is elaborate
elaborate PASS_ELABORATE, less everything else in this list it contains
typeclass defclass/definstance, method dispatch, instance resolution
kind-check, effect-env, effect-row, cps, borrow-check the core passes of the same names (effect-env was effect-lower until 2026-10-21)
refine refinement obligations: frame checks, call-site crossings, the solver
mono the by-value spec resolution pass (monomorphs instantiated inline are charged to elaborate or emit, wherever that happens)
emit producing C, including the driver's split-preamble swap and the JIT's prune
cc wall time the driver spends waiting on toolchain subprocesses (cc, the link, nm)
jit tur jit: c2mir, the MIR link, and tearing the context down
run the program's own execution under tur run / tur jit (not compile time; the report says so)
other what no zone claims: argument parsing, path and manifest resolution, writing outputs

The times are exclusive: a phase's self ms is time spent in it and in nothing nested inside it. Elaboration recursively loads modules which read files which are elaborated in turn, and the timer keeps an explicit zone stack and charges each stretch of time to the innermost open zone, so the phases are a partition of the wall clock: sum(self_ms) == wall_ms by construction, and the percentages add to 100. An example table, tur build of benchmarks/triangle/fib.tur on a Release tur (x86-64 Linux, warm caches, one run -- illustrative, not a benchmark):

tur --timings: tur build --timings fib.json benchmarks/triangle/fib.tur -o fib
  phase              self ms   self %
  read                 1.811      1.0
  module-load          0.526      0.3
  macro-expand         0.012      0.0
  elaborate            4.219      2.4
  typeclass            0.801      0.5
  kind-check           0.038      0.0
  effect-env           0.001      0.0
  effect-row           0.469      0.3
  cps                  0.023      0.0
  borrow-check         0.069      0.0
  refine               0.154      0.1
  mono                 0.001      0.0
  emit                 6.608      3.8
  cc                 156.637     90.8
  other                1.191      0.7
  total (wall)       172.560    100.0
  (exclusive time: a phase's own time, nested phases excluded; 1162 clock reads)

The table leaves out phases that took no time; the JSON (fib.json above) lists every phase, in the order of the table, as the tur-profile/1 document of kind compiler:

{
  "schema": "tur-profile/1",
  "kind": "compiler",
  "tur_version": "0.65.1",
  "command": "tur build --timings fib.json benchmarks/triangle/fib.tur -o fib",
  "started_utc": "2026-10-15T02:20:27Z",
  "wall_ms": 172.5600,
  "notes": [],
  "clock_reads": 1162,
  "phases": [
    {"name": "read", "self_ms": 1.8110, "self_pct": 1.0495},
    {"name": "sweet", "self_ms": 0.0000, "self_pct": 0.0000},
    ...
    {"name": "cc", "self_ms": 156.6371, "self_pct": 90.7726},
    {"name": "jit", "self_ms": 0.0000, "self_pct": 0.0000},
    {"name": "run", "self_ms": 0.0000, "self_pct": 0.0000},
    {"name": "other", "self_ms": 1.1905, "self_pct": 0.6899}
  ]
}

notes carries the run's honesty caveats, and the table prints them above the phases: a sanitized tur (the Debug build -- profile a Release tur, ASan's bookkeeping skews every phase), a Debug build's assertions (each core pass also re-verifies kind info), a run phase present, or an exit from inside a phase. clock_reads is the timer's own cost: one monotonic clock read per zone change, so a few thousand of them are well under a millisecond. With the flag off every hook is a single branch, and the emitted C is byte-identical either way.

Two things the numbers do not say:

On WSL, a large cc phase may be your PATH. WSL appends the Windows PATH to the distro's, and gcc's linker driver (collect2) looks for its linker in every PATH directory, so each link probes the /mnt/c/... entries over WSL's slow file share. On the audit box that was ~65 ms of every link: an empty program's tur build took 131 ms with the Windows entries and 79 ms without. To check, time a build with PATH cut down to the Linux directories; to fix it, set appendWindowsPath = false under [interop] in /etc/wsl.conf, or drop the /mnt/ entries in your shell profile.

--timings changes no program's meaning, so it is a plain flag, not an --enable= experiment. The design and what comes next (per-module timings, the program-side --profile) are in profiling-plan.md. tur jit --timing-json <path> still writes its own {"compile_ms", "run_ms", "engine"} record for the benchmark harness, unchanged.

Profiling

A benchmark says how long; a profile says where. There are three instruments, one per question. All three were used by the 2026-10 performance audit, whose measured results -- the box, the corpus, compile latency, runtime hot paths, and ranked fix tables -- are in docs/artifacts/perf-audit-2026-10/. Start with that README for the machine and the method.

A compiled program: perf and tur demangle

A program tur build produced is an ordinary native binary, so any native profiler works on it: perf on Linux, Instruments or samply on macOS. What makes the output hard to read is the names. Every Turmeric function appears under its mangled C name -- checked-div as checked_hydiv, geom/vector/add2 as geom__vector__add2 -- and tur demangle turns them back. It filters any text on stdin, rewriting the names it recognizes, or decodes its arguments one per line:

tur build prog.tur -o prog
perf record ./prog                                  # samples, self time per symbol
perf report --stdio --no-children | tur demangle    # the per-symbol table, readable
perf script | tur demangle > prog.perf              # every sample, for a flame-graph tool
nm ./prog | tur demangle                            # any tool's output
tur demangle geom__vector__add2                     # -> geom/vector/add2

--strict rewrites only module-qualified names (those with the __ separator): no false positives, but bare top-level names stay mangled. --annotate prints source[mangled], so the C name stays greppable. The scheme is in the name mangling guide.

For call graphs, build with debug info and let perf unwind with DWARF. TUR_CC_FLAGS replaces the default C flags, so restate them:

TUR_CC_FLAGS="-O2 -g -std=c99 -Wall -fno-strict-aliasing" tur build prog.tur -o prog
perf record --call-graph dwarf ./prog

tur build --debug adds -g too, but at -Og, which profiles a different program from the one you ship.

Reading the profile. Runtime symbols group by prefix; this is the table benchmarks/audit/subsys-profile.py uses to bucket a whole profile by subsystem:

symbol prefix subsystem
__dk_, dk_, __tur_cps_, tur_cont, tur_tramp CPS and delimited-control frames
rc_, tur_rc_ reference counting
gc_, tur_gc_ the cycle collector; in r7rs / saffron, the dialect collector
tur_hamt_, node_insert, node_has HAMT maps
tur_region_, reg_, arena_ regions
tur_trail_, tur_uf_, tur_bt_ the backtracking trail
tur_tls_ the split preamble's thread-local accessors (below)
malloc, free, _int_ the C allocator

What the audit's corpus showed is normal (WP2), so a profile that looks like this is not a surprise:

The compiler: --timings

tur build --timings[=<path>] (and check, emit-c, run, jit) reports where the compiler's own time went, as eighteen exclusive zones that partition the wall clock (read, elaborate, the core passes, emit, cc, ...); with a path it also writes the table as a tur-profile/1 JSON document. Compiler timings above has the zones, an example table and the JSON shape. Use a Release tur: the Debug build's ASan and UBSan skew every zone, and the report says so in its notes. Over the audit's corpus the C compiler was 91% of tur build, the front end a median 8 ms, and an empty program still emitted 425 KB of prelude C that gcc mostly parses and discards (WP1).

tur build now hands gcc the pruned unit tur jit hands c2mir, with only what the program reaches. For fib that cuts the unit from 283 KB to 124 KB and gcc's compile from 125 ms to 52 ms, and corpus builds are ~8% faster (geomean 0.92). Most of a small build is now the link: about 70 ms of ld over libturt_preamble.a and libturt_runtime.a with --gc-sections. TUR_CC_NO_PRUNE=1 turns the prune off. A TUR-W0071 from tur build means it dropped something live and the full unit was compiled instead; that is a bug, so please report it. For tur jit, TUR_JIT_TIMING=1 (below) splits the JIT leg further.

Counters

Environment variables that make the compiler, or a compiled program, report on itself. All of them print to stderr, so stdout is unchanged. "On tur" means set it when compiling; "on the program" means set it when running the binary (or tur run).

variable set on prints
TUR_REGION_STATS=1 the program at exit, region-stats: pushes=N rewinds=N retires=N: whether region brackets rewound (memory saved) or retired (memory kept)
TUR_GC_TRACE=1 the program one [gc] #N mode=M candidates=C freed=F live=A->B line per cycle-collector run (gc guide)
TUR_GC_STATS=1 an r7rs or saffron program at exit, the dialect collector's collections, bytes freed, live size at the last collection, heap and peak heap
TUR_REFINE_STATS=1 tur per unit, refinement obligations proven / refuted / unknown, then each solver cap's peak against its limit (solver guide)
TUR_REPR_CENSUS=1 tur one repr-census <position> <form> line per value-representation decision
TUR_TRACE_EVICT=1 tur one [EVICT] <category> eff=N <function> <reason> line per function the CPS backend declined
TUR_SHOW_CC=1 tur the assembled C compiler command (CC: ...), and why a prelude or library split was declined
TUR_CPS_CENSUS=1 tur one cps-census line per function: colored or not, and why (control, callee, or indirect -- a call through a function value and nothing else)
TUR_JIT_TIMING=1 tur jit tab-separated milliseconds per phase (c2mir, gen_init, load, link, run), the prune's byte counts, peak RSS and the total (JIT guide)

benchmarks/audit/ holds the scripts that ran these over a whole corpus: cps-census.py (TUR_CPS_CENSUS), timings-corpus.py (--timings) and subsys-profile.py (perf).

Performance checklist

Use this list before calling a hot path done: