Unexpected behaviour when using Pluto with StaticArrays

Hello everyone.

I’ve found this weird behaviour when using StaticArrays within a begin … end block Pluto.

I have this cell

begin
     xᵢ = collect(range(0, 10, length=n)) |> SVector{n}
     yᵢ = @SVector [β₀ + β₁ * i + rand() for i in xᵢ]
end

where β₀ and β₁ are just normal floats, and n is an int. The problem is that, when I run the code like this it wont work! It returns me a UndefVarError: xᵢ not defined in Main.var"workspace#3". If I run the variable declaration for xᵢ and yᵢ separetely, that is, in different cells, everything works fine. It is only when they are together.

Does anyone knows what might be going on?

My versioninfo() and ]status. Pluto version=v0.20.8.

julia> versioninfo()
Julia Version 1.11.5
Commit 760b2e5b739 (2025-04-14 06:53 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: macOS (arm64-apple-darwin24.0.0)
  CPU: 10 × Apple M4
  WORD_SIZE: 64
  LLVM: libLLVM-16.0.6 (ORCJIT, apple-m1)
Threads: 4 default, 0 interactive, 2 GC (on 4 virtual cores)
Environment:
  JULIA_REVISE_WORKER_ONLY = 1

julia> import Pkg; Pkg.status()
Status `/private/var/folders/05/16l0_s8d0g3680ryypw6qw7r0000gn/T/jl_IBJiAk/Project.toml`
  [6e4b80f9] BenchmarkTools v1.6.0
  [31c24e10] Distributions v0.25.120
  [38e38edf] GLM v1.9.0
⌃ [91a5bcdd] Plots v1.40.13
  [90137ffa] StaticArrays v1.9.13
  [10745b16] Statistics v1.11.1
  [f3b207a7] StatsPlots v0.15.7
  [9d95f2ec] TypedTables v1.4.6
  [44cfe95a] Pkg v1.11.0
  [9a3f8284] Random v1.11.0
Info Packages marked with ⌃ have new versions available and may be upgradable.

I don’t think this is a Pluto issue. Running this begin ... end block in a fresh REPL gives the same error:

using StaticArrays

begin
     xᵢ = collect(range(0, 10, length=10)) |> SVector{10}
     yᵢ = @SVector [i for i in xᵢ]
end

# ERROR: LoadError: UndefVarError: `xᵢ` not defined

Not sure if the details/nomenclature here are technically correct, but macros are expanded before actual code is run in the expression you are trying to evaluate. So the whole expression in the block is parsed first, then the macros get replaced by the expressions they generate, and only then the code will run.

The error happens in the second step when the @SVector macro is trying to generate the expression that takes the values of xᵢ and use them to create yᵢ. Since the code to define xᵢ has not been run yet (happens after macro expansion), this cannot work. When generating an @SVector from the comprehension above, it basically reads all the values directly from the vector in the expansion (so xᵢ) and pastes them into the new expression (see below). But xᵢ hasn’t been defined yet, so the values cannot be read at the time of macro expansion.

I wonder if technically the SVector macro could do something else and just use the variable name xᵢ when generating the expression for the new vector, but apparently this doesn’t work (?). It might be because e.g. the length of the vector would have to be inferred somehow, probably only possible with length(xᵢ) at which point you would loose type stability.

One solution would be to just use SVector{n} as in the line above, since you already know the length and don’t really need to fall back to the convenience constructor. This will allocate the array [... for i in xᵢ] first and then convert it, but that doesn’t sound like a performance bottleneck.

begin
    xᵢ = collect(range(0, 10, length=10)) |> SVector{10}
    yᵢ = SVector{10}([i for i in xᵢ])
end
`@macroexpand`

This is the @macroexpanded @SVector expression (after xᵢ has been successfully defined). The values from xᵢ are pasted literally in the vector:

let
    var"#3#f"(i) = begin
        i
    end
    (SVector){10}((tuple)(var"#3#f"(0.0), var"#3#f"(1.1111111111111112), var"#3#f"(2.2222222222222223), var"#3#f"(3.3333333333333335), var"#3#f"(4.444444444444445), var"#3#f"(5.555555555555555), var"#3#f"(6.666666666666667), var"#3#f"(7.777777777777778), var"#3#f"(8.88888888888889), var"#3#f"(10.0)))
end

If the expression would return this expression

(SVector){10}((tuple)(var"#3#f"(xᵢ[1]), var"#3#f"(xᵢ[2]), var"#3#f"(xᵢ[3]),  ... ))

It might work, but at the moment I don’t understand the implications of doing that.

2 Likes

This is also why we can’t put the import of a macro in the same expression as a macro call; the macro call happens before the expression, including the import, is evaluated.

julia> begin
       using BenchmarkTools
       @btime $1+$1
       end
ERROR: LoadError: UndefVarError: `@btime` not defined in `Main`

Note that more often than not, you can use a global variable defined in the same block as a macro call:

julia> begin
       x = 1
       @time 10x
       end
  0.000008 seconds
10

because macros usually just transform expressions to be evaluated with the rest of the expression later. @SVector (and related StaticArrays macros) are unusual in that the macros evaluate the symbol in the global scope to get a value first. Although this makes the macros useless for variables in local scopes:

julia> let z=3;
       SVector{z}(i for i in 1:z)
       end
3-element SVector{3, Int64} with indices SOneTo(3):
 1
 2
 3

julia> let z=3;
       @SVector [i for i in 1:z]
       end
ERROR: LoadError: UndefVarError: `z` not defined in `Main`
...

it allows you to not provide a length parameter in the global scope (see there’s only one z in the macro call) and the macro will hardcode the elements into an SVector{#=length=#} call instead of allocating an intermediate array for the comprehension. Of course, the non-macro SVector supports generator expressions, so that’s another way to skip that allocation.

2 Likes