# 12. Performance

> Chapter 12: how to find out what the compiler did with alice explain and alice emit, SIMD vectorization, structure-of-arrays records, inlining, measuring correctly, reading benchmarks, and GC settings.

Canonical: https://alice-lang.org/documentation/learn/performance/

[Documentation index](https://alice-lang.org/documentation/llms.txt) · [Complete book](https://alice-lang.org/llms-full.txt)

The last three chapters showed optimizations that Alice applies on its own: fused pipelines, owned arrays, forks and parallel loops. This chapter is about the craft around them. How do you see what the compiler did? How do you measure without fooling yourself? And which few switches are worth knowing?

The most important rule comes first: **measure before you change code for speed**. Most of a program's time is spent in a few places. Making the rest faster changes nothing you can notice.

## 12.1 Ask the compiler: `alice explain`

`alice explain` lists, per method, the optimizations it received. It is the quickest way to check whether hot code got what you expected:

```alice cmd="explain"
def count_expensive(prices : Array(Float64), limit : Float64) : Int32
  prices.count { |p| p > limit }
end

prices = Array(Float64).new(1000) { |i| i * 0.5 }
puts count_expensive(prices, 100.0)
```

```text title="Output"
╭─────────────────┬───────────────╮
│ method          │ optimizations │
├─────────────────┼───────────────┤
│ count_expensive │ SIMD          │
│ Main            │ none          │
╰─────────────────┴───────────────╯
```

The entries you will see most often:

| Entry                                        | Meaning                                     | Chapter                                                         |
| -------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------- |
| owned array: pooled buffer                   | no GC allocation once warm                  | [9](https://alice-lang.org/documentation/learn/ownership.md)    |
| fork/join                                    | independent recursive calls run in parallel | [11](https://alice-lang.org/documentation/learn/parallelism.md) |
| parallel loop                                | iterations spread over all cores            | [11](https://alice-lang.org/documentation/learn/parallelism.md) |
| SIMD                                         | one instruction processes several numbers   | 12.2                                                            |
| compiled fully optimized from the first call | the JIT skips its quick first pass          | 12.4                                                            |

When a hot method shows `none`, add the explicit keyword (`own`, `par for`, `pure`). The resulting error tells you why the optimization did not apply.

## 12.2 SIMD: several numbers per instruction

Modern CPUs have **vector** instructions: one instruction adds, compares or multiplies 4, 8 or 16 numbers at once. This is called SIMD, "single instruction, multiple data". Using it by hand means writing special code for each CPU generation.

Alice does it for numeric pipelines over arrays: `count`, `sum`, `min`, `max` and `map` with arithmetic and comparisons in the block. `count_expensive` above is compiled into a loop over vectors, with a check at startup for the widest vector size the CPU supports. On a machine with AVX-512, each step compares 8 `Float64` values at once.

`alice emit` shows the generated code. A line from `count_expensive`:

```csharp
__vacc17 += Vector512.ConditionalSelect(Vector512.GreaterThan(__v16, Vector512.Create((double)limit)), Vector512<double>.One, Vector512<double>.Zero);
```

You wrote `prices.count { |p| p > limit }`. A block that calls something the vector unit cannot do, such as `Math.Sin`, stays a normal loop.

### Many records, one column at a time

Programs that simulate many things, such as particles, enemies or accounts, often loop over all of them and touch one or two fields. With an array of records, each record's fields sit next to each other, and the loop drags all of them through the cache.

A **structure of arrays** stores each field in its own array instead. Mark a record struct with `@[SoA]` and keep it in a `SoA(T)`:

```alice
@[SoA]
record struct Particle(x : Float64, v : Float64)

ps = SoA(Particle).new
10.times { |i| ps.add(Particle.new(i.to_f, 1.0)) }
ps.each { |p| p.x += p.v * 0.5 }
puts ps.sum { |p| p.x }
puts ps.size
```

```text title="Output"
50.0
10
```

The code reads like a list of records. Underneath, `x` and `v` are two arrays of `Float64`, and `each` and `sum` become vectorized loops over them.

## 12.3 Measuring correctly

To time code yourself, use .NET's `Stopwatch`:

```alice
sw = System.Diagnostics.Stopwatch.StartNew
s = 0_i64
for i in 0...10_000_000
  s += i % 3
end
sw.Stop
puts "checksum #{s}"
puts "took #{sw.ElapsedMilliseconds} ms"
```

The time differs on every machine and every run, so the book only checks that this compiles. Four habits make such numbers meaningful:

1. **Print a checksum.** If the result is not used, an optimizer may remove the work, and you measure nothing. A checksum also proves that two versions compute the same thing.
2. **Warm up.** The first call of a method includes compiling it. Run it a few times before timing, or report the first run separately as the cold time.
3. **Repeat and take the median.** A single run is noise. Tools like `hyperfine` run a program many times and report the spread.
4. **Compare on one machine only.** Different CPUs, core counts and memory give different results. A number from another machine is not a comparison.

### Reading a benchmark table

Alice's repository contains benchmarks against C, C# and other languages. When you read one, ask these questions:

| Question                   | Why it matters                                                                   |
| -------------------------- | -------------------------------------------------------------------------------- |
| Cold process or warm loop? | A cold run includes startup and JIT time, about 20 to 40 ms for a .NET program   |
| One core or all cores?     | A parallel result says little about single-thread speed, and the reverse         |
| Same algorithm?            | A faster algorithm beats any compiler. A fair benchmark ports the same algorithm |
| Checksums equal?           | Different output means different work                                            |
| Which machine?             | Only numbers from the same machine compare                                       |

## 12.4 A few switches

### Inlining and hot methods

Calling a tiny method in a hot loop costs a jump and stops some optimizations. The .NET JIT usually inlines small methods by itself. When it does not, `inline def` asks for it:

```alice cmd="explain"
inline def half(x : Float64) : Float64
  x * 0.5
end

@[Hot]
def total(n : Int32) : Float64
  s = 0.0
  for i in 0...n
    s += half(i.to_f)
  end
  s
end

puts total(10)
```

```text title="Output"
╭────────┬──────────────────────────────────────────────╮
│ method │ optimizations                                │
├────────┼──────────────────────────────────────────────┤
│ half   │ none                                         │
│ Main   │ none                                         │
│ total  │ parallel loop                                │
│        │ compiled fully optimized from the first call │
╰────────┴──────────────────────────────────────────────╯
```

`@[Hot]` tells the JIT to optimize a method fully from its first call, instead of starting with quick, unoptimized code. Use it on methods that run a long loop once. Use `inline def` only for tiny methods: inlining large bodies grows the code and can slow it down.

### The garbage collector

.NET has two GC modes. **Workstation** GC uses one heap and starts fast, which suits command-line tools and games. **Server** GC uses one heap per core, which suits long-running parallel work. In `alice.toml`:

```toml
[runtime]
gc = "auto"   # default: server GC when the program forks or runs parallel loops, else workstation
# gc = "workstation"
# gc = "server"
# gc = "low-memory"   # smallest footprint
```

The default is a good start. Change it only after measuring your real workload.

### Native code

`alice build --release` produces optimized IL, which the JIT compiles at startup. `alice build --native` produces a NativeAOT executable: no JIT, faster startup, one file. [Chapter 18](https://alice-lang.org/documentation/learn/packaging.md) covers it, and [chapter 17](https://alice-lang.org/documentation/learn/native.md) shows `@[Native]`, which compiles a numeric method with a C compiler.

## Project: checking the hot path

The tea shop's hottest code is the year report from chapter 11. Run `alice explain` on the pricing core and the report, and check each line against what you expect:

```alice cmd="explain"
def revenue(cents : Array(Int64)) : Int64
  cents.sum
end

def big_orders(cents : Array(Int64), limit : Int64) : Int32
  cents.count { |c| c > limit }
end

def doubled(cents : Array(Int64)) : Int64
  cents.map { |c| c * 2 }.sum
end

cents = Array(Int64).new(100_000) { |i| ((i * 37) % 900 + 100).to_i64 }
puts revenue(cents)
puts big_orders(cents, 800_i64)
puts doubled(cents)
```

```text title="Output"
╭────────────┬───────────────╮
│ method     │ optimizations │
├────────────┼───────────────┤
│ big_orders │ SIMD          │
│ doubled    │ SIMD          │
│ Main       │ none          │
│ revenue    │ none          │
╰────────────┴───────────────╯
```

`big_orders` and `doubled` are vectorized pipelines. `revenue` shows `none` because `sum` on a large array is a call into the runtime, which already splits the work into SIMD chunks on all cores. That is the habit to learn: read the report, and when an entry surprises you, find out why before changing code.

:::caution\[Common mistakes]

- **Optimizing without a measurement.** Find the hot spot first, with a profiler or a `Stopwatch` around candidates.
- **Timing a result you never use.** Print a checksum, or the work may be optimized away.
- **Comparing numbers from different machines.** Only compare runs on the same machine, with the same settings.
- **Inlining big methods.** `inline def` is for tiny helpers.
- **Changing the GC mode by habit.** The default adapts to the program. Change it after measuring.
  :::

## Exercises

1. Write `max_temperature(xs : Array(Float64)) : Float64` with `xs.max`, and a version with a `for` loop. Check both with `alice explain`. Which got SIMD?
2. Time `(1..10_000_000).sum` with a `Stopwatch`, running it five times. Is the first run slower? Why?
3. A benchmark says "Alice 12 ms, C 15 ms" on a parallel workload with 32 cores, and the C version is single-threaded. What can you conclude?

<details>
<summary>Answers</summary>

1. Write both and read the report. The pipeline form is the one the vectorizer looks for; the hand-written loop with a branch is compiled as written:

   ```alice cmd="explain"
   def max_pipeline(xs : Array(Float64)) : Float64
     xs.map { |x| x * 1.0 }.max
   end

   def max_loop(xs : Array(Float64)) : Float64
     best = xs[0]
     for x in xs
       best = x if x > best
     end
     best
   end

   xs = Array(Float64).new(100) { |i| (i % 17).to_f }
   puts max_pipeline(xs)
   puts max_loop(xs)
   ```

   ```text title="Output"
   ╭──────────────┬───────────────╮
   │ method       │ optimizations │
   ├──────────────┼───────────────┤
   │ Main         │ none          │
   │ max_loop     │ none          │
   │ max_pipeline │ SIMD          │
   ╰──────────────┴───────────────╯
   ```

2. Usually yes. The first run includes the JIT compiling the code, and the CPU caches and clock speed settle during the first runs. That is why benchmarks warm up first and report cold runs separately.

3. Very little about the compilers. The comparison mixes 32 cores against one. A fair comparison would put the parallel Alice result next to a parallel C version, or the single-core Alice result next to the single-threaded C.

</details>

## Summary

- Measure first; optimize the few places that matter.
- `alice explain` lists the optimizations of each method; `alice emit` shows the generated C#.
- Numeric pipelines over arrays are vectorized (SIMD). `@[SoA]` records store fields as columns for fast loops.
- Time with a `Stopwatch`, print a checksum, warm up, repeat, and compare on one machine only.
- `inline def`, `@[Hot]`, the `[runtime] gc` setting and native builds are the few switches worth knowing.

[Chapter 13](https://alice-lang.org/documentation/learn/proofs.md) moves from speed to certainty: proving that a rule holds for every input.

## Continue reading

- Previous: [11. Parallelism without threads](https://alice-lang.org/documentation/learn/parallelism.md)
- Next: [13. Rules and proofs](https://alice-lang.org/documentation/learn/proofs.md)
