# Writing Fast Julia

Everything in this book has been building toward this chapter.

Julia's promise is that you can write high-level code and get low-level speed. That promise is real, but it isn't automatic. The gap between slow Julia and fast Julia is often 10x or 100x, and it usually comes down to a handful of specific, learnable mistakes. The good news is that Julia gives you excellent tools for finding them — so this chapter is less "memorize these rules" and more "here's how to ask the compiler what it thinks."

## The one rule underneath all the others

**The compiler must be able to predict the type of every value.**

That's it. When Julia can infer that a variable holds a `Float64`, it emits a machine instruction that adds two floats. When it can only infer `Any`, it emits code that inspects the value at runtime, looks up which `+` method applies, allocates a box for the result, and moves on. The second is easily fifty times slower and allocates on every operation.

Code where every type is predictable is called **type stable**. Everything below is a specific way to achieve it, or a specific way people accidentally break it.

## Rule 1: Put your work inside functions

I planted this back in the control flow chapter. Here's the payoff.

```{code-block} julia
# In a script, at top level — SLOW
total = 0.0
for i in 1:10_000_000
    global total += sqrt(i)
end
```

```{code-block} julia
# Inside a function — FAST
function accumulate_sqrt(n)
    total = 0.0
    for i in 1:n
        total += sqrt(i)
    end
    return total
end
```

The difference is often an order of magnitude, and the reason is simple: `total` at the top level is a global variable. Any other code, at any time, could reassign it to a string. The compiler must assume nothing about its type. Inside a function, `total` is a local — the compiler can see every assignment to it and knows it's always a `Float64`.

**Wrap your work in functions.** If you take one piece of advice from this chapter, take that one.

If you genuinely need a global, make it `const`:

```{code-block} julia
const TAX_RATE = 0.0825      # type is now known and fixed
```

## Rule 2: Watch out for type instability

A function is type unstable when its return type depends on the *values* of its arguments rather than their types.

```{code-block} julia
# UNSTABLE: returns Int or Float64 depending on the value
function bad_sign(x)
    if x > 0
        return 1          # Int
    else
        return -1.0       # Float64
    end
end
```

Julia can only infer `Union{Int64, Float64}` here, and every caller pays for that uncertainty.

```{code-block} julia
# STABLE
function good_sign(x)
    return x > 0 ? 1.0 : -1.0
end
```

A subtler and much more common version:

```{code-block} julia
# UNSTABLE — accumulator starts as Int, becomes Float64
function sum_scaled(xs)
    total = 0                # Int64!
    for x in xs
        total += x * 1.5     # now it needs to be Float64
    end
    return total
end
```

```{code-block} julia
# STABLE — start with the right type
function sum_scaled(xs)
    total = zero(eltype(xs)) * 1.5      # or just 0.0 if you know
    for x in xs
        total += x * 1.5
    end
    return total
end
```

Initializing an accumulator with the wrong type is, I'd guess, the single most common type instability in beginner Julia. Watch for it.

### `@code_warntype` — your best diagnostic

Julia will tell you exactly where inference failed:

```{code-block} julia
julia> @code_warntype bad_sign(3)
```

The output lists every variable with its inferred type. Anything printed in **red** (or shown as `Any` or a big `Union`) is a problem. Anything in blue or plain is fine.

You don't need to understand the whole output. Scan for red. That's the skill.

For a more automated version, `JET.jl` analyses a whole call graph and reports instabilities without you having to inspect each function:

```{code-block} julia
using JET
@report_opt my_function(data)
```

## Rule 3: Give containers concrete element types

```{code-block} julia
bad = []                      # Vector{Any} — every element is a pointer
good = Float64[]              # Vector{Float64} — contiguous, dense

bad_dict = Dict()             # Dict{Any,Any}
good_dict = Dict{String,Int}()
```

This is the "abstract in signatures, concrete in storage" rule from the type system chapter, applied. The same principle covers struct fields:

```{code-block} julia
struct Slow            # every field is Any
    a
    b
end

struct Fast
    a::Float64
    b::Int
end
```

And it covers a case that catches people out:

```{code-block} julia
struct AlsoSlow
    values::Vector{Real}      # abstract element type — pointers again
end

struct AlsoFast{T<:Real}
    values::Vector{T}         # parametric — concrete once constructed
end
```

If you want a struct field to hold different numeric types in different instances, **make the struct parametric.** Don't reach for an abstract field type.

## Rule 4: Don't allocate in hot loops

We covered the mechanics in the last chapter; here's the discipline.

```{code-block} julia
# Allocates a temporary array on every iteration
function slow_norms(rows)
    out = Float64[]
    for i in axes(rows, 1)
        push!(out, sqrt(sum(rows[i, :] .^ 2)))     # rows[i,:] copies!
    end
    return out
end
```

```{code-block} julia
# Preallocated output, views instead of copies
function fast_norms(rows)
    out = Vector{Float64}(undef, size(rows, 1))
    for i in axes(rows, 1)
        r = @view rows[i, :]
        out[i] = sqrt(sum(abs2, r))
    end
    return out
end
```

Three changes: preallocate `out`, use `@view` instead of slicing, and use `sum(abs2, r)` which fuses the squaring into the sum instead of materializing a squared array.

## Measuring properly

Do not trust `@time` for anything small. It measures a single run, includes compilation on the first call, and is noisy.

```{code-block} julia
using BenchmarkTools

@btime sum($xs)              # runs many times, reports the minimum
@benchmark sum($xs)          # full statistics
```

Note the `$`. In a benchmark, `$xs` interpolates the variable so you're not accidentally measuring the cost of looking up a global. **Forgetting the dollar sign is the classic benchmarking mistake in Julia** and will make everything look slower than it is.

`@time` remains useful for one thing: its allocation count. If `@time` says a function that should do no allocation allocated 4 million times, you have your lead.

## Profiling: find the hot spot before optimizing it

```{code-block} julia
using Profile

@profile my_analysis(data)
Profile.print()
```

Better, use `ProfileView.jl` or the VS Code extension's profiler for a flame graph. Look for the wide bars — those are where the time goes.

I want to state the obvious rule that everybody knows and nobody follows: **profile first.** Programmer intuition about where time goes is unreliable, mine very much included. I have spent afternoons carefully optimizing a function that turned out to account for 2% of the runtime.

## The sharper tools

Once your code is type stable and not allocating, there are a few more levers. Use them last, and measure each one.

```{code-block} julia
@inbounds for i in eachindex(xs)     # skip bounds checks
    ...
end

@simd for i in eachindex(xs)         # permit vectorization
    ...
end

@fastmath a * b + c                  # relax floating-point strictness
```

:::{warning} These are genuinely dangerous
`@inbounds` disables the check that catches out-of-bounds indexing. Get the index wrong and instead of a clean error you read arbitrary memory, or corrupt it. Only use it on loops where you can prove the indices are valid — and `eachindex` is how you prove it.

`@fastmath` permits reassociating floating-point operations, which changes results. Fine for a graphics kernel; not fine for a financial calculation or a numerically delicate algorithm.

I'd add: check whether you need these at all. The compiler often eliminates bounds checks on its own when it can see the loop is safe, and modern LLVM vectorizes many loops without `@simd`.
:::

## A worked optimization

Let's take a realistic function through the whole process. The task: for each account in a ledger, compute how far its balance deviates from the mean, in standard deviations.

**Version 1 — the way you'd naturally write it first:**

```{code-block} julia
function zscores_v1(balances)
    results = []
    m = sum(balances) / length(balances)
    s = sqrt(sum((balances .- m) .^ 2) / length(balances))
    for b in balances
        push!(results, (b - m) / s)
    end
    return results
end
```

`@btime` on a million elements: slow, and lots of allocations. `@code_warntype` shows `results` is a `Vector{Any}`.

**Version 2 — fix the container type:**

```{code-block} julia
function zscores_v2(balances)
    results = Float64[]
    ...
end
```

Better. Still allocating as the vector grows.

**Version 3 — preallocate:**

```{code-block} julia
function zscores_v3(balances)
    n = length(balances)
    m = sum(balances) / n
    s = sqrt(sum(abs2, balances .- m) / n)
    results = Vector{Float64}(undef, n)
    for i in eachindex(balances)
        results[i] = (balances[i] - m) / s
    end
    return results
end
```

Now it's one allocation for the output plus one temporary in the standard deviation.

**Version 4 — remove the last temporary and let broadcasting fuse:**

```{code-block} julia
using Statistics

function zscores_v4(balances)
    m = mean(balances)
    s = std(balances; corrected = false)
    return @. (balances - m) / s
end
```

Shorter than version 1, and fast. That's the arc I want you to notice: the *readable* version and the *fast* version converged. The optimization didn't make the code uglier — the intermediate versions were uglier than both ends.

That is the thing I like most about optimizing Julia. In many languages, making code fast means making it worse. Here, it usually means making it clearer about what it actually does.

## A checklist

When something is slower than you expect, in order:

1. Is the work inside a function?
2. Are you benchmarking with `@btime` and `$`-interpolated variables?
3. Does `@code_warntype` show red?
4. Are your containers and struct fields concretely typed?
5. Is `@time` reporting allocations you didn't expect?
6. Have you profiled to confirm you're optimizing the right function?
7. Only then: views, preallocation, `@inbounds`, `@simd`.

## Try it yourself

1. Write the top-level loop version and the function version of summing `sqrt(i)` for a million values. Time both. Note the ratio.
2. Write a type-unstable function and run `@code_warntype` on it. Find the red.
3. Take `zscores_v1` above and work it up to `v4` yourself, benchmarking at each step.
4. Benchmark `sum(xs)` with and without the `$` in `@btime`. Explain the difference.

You can now write Julia that's genuinely fast on one core. Next: using all of them.
