Dynamically Identifying Linearity Bugs in Lean 4
One of the key ingredients for writing performant Lean code is its functional but in-place (FBIP) idiom. While Lean does have automatic memory management, it does not use a normal garbage collector, unlike its functional relatives. Instead, every allocation is managed via reference counting. This allows Lean programs to determine at execution time whether an allocation is unique and update it in place instead of performing a copy-on-write.
The most important FBIP data structure in Lean is Array. As long as the reference count
of an Array is equal to 1, it will be updated in place and have the same asymptotics as a std::vector. Otherwise, it acts as
a copy-on-write data structure. On top of Array Lean builds many other data types, such as the
imperative Std.HashMap and Std.HashSet. Thanks to the in-place updates, all of these data
structures can maintain the asymptotics of imperative data structures, as long as they are
referenced uniquely. On the flip side, once they do fall back to their copy-on-write behavior, the
asymptotics usually degrade by an O(n) factor or worse. Because the property of being uniquely referenced
is closely related to linear type systems,
the Lean community calls these types "linear data structures", and performance issues due to violating
this property are "linearity bugs".
Linearity Bugs
Linearity bugs usually occur when a linear data structure gets kept alive slightly too long in order
to be eligible for an in-place update. For example, in the following code we would like xs to be
updated in place:
def pushIt (xs : Array Nat) (n : Nat) : Array Nat × Nat :=
let ys := xs.push n
(ys, xs.size)
However, the fact that xs is still used after the creation of ys means that its reference count
will be 2 instead of 1 inside Array.push. Because of this, Array.push will first create a
copy of xs and then push onto it. To avoid this copy, we have to write the following:
def pushIt (xs : Array Nat) (n : Nat) : Array Nat × Nat :=
let sz := xs.size
let ys := xs.push n
(ys, sz)
While the non-linearity in this example is easy to spot, there are many other ways to mess up linearity. The most common one occurs in combination with monadic state:
abbrev M := StateRefT (Array Nat) IO
def pushIt (n : Nat) : M Unit := do
let xs ← get
let ys := xs.push n
set ys
In this example, xs is also used non-linearly because the state monad still maintains a reference
to it while Array.push is being executed. In order to achieve a linear update, we must either
manually clear the state before doing a push or use the modify operation, which ensures that the
state monad doesn't hold a reference to the Array as it runs:
abbrev M := StateRefT (Array Nat) IO
def pushIt (n : Nat) : M Unit := do
modify fun xs => xs.push nDetecting Linearity Bugs
In the past, there has been some work towards building a type system extension for Lean that can enforce linearity constraints, in a similar vein to Rust's type system. Unfortunately, this line of work is not yet at the point where it can be used in production, so linearity bugs cannot be detected in a static fashion today.
Instead of detecting linearity issues statically, we can attempt to identify them dynamically at
execution time. This kind of approach has already proven very successful for detecting things like
memory safety issues in C and C++ using tools like AddressSanitizer or ThreadSanitizer. However, not
all copy-on-write uses of an Array are linearity bugs. For example, the built-in hash array mapped
trie type Lean.PersistentHashMap makes use of the copy-on-write behavior of Array.
For this reason, we cannot declare every copy-on-write on an Array as a bug.
Given that only the author of a program knows which data structures are expected to be used linearly
and non-linearly, we can also expect them to tell us about this. For this purpose, Lean v4.35.0-rc1
introduced markLinear functions on all built-in data structures that are supposed to be
used linearly. If a value has been markLinear-ed and the environment variable
LEAN_ABORT_ON_NONLINEAR is set, Lean will crash on the first copy-on-write access to this value.
This already makes it possible to detect the above linearity issue:
abbrev M := StateRefT (Array Nat) IO
def pushIt (n : Nat) : M Unit := do
let xs ← get
let ys := xs.push n
set ys
def main (args : List String) : IO Unit := do
let some size := String.toNat? args[0]! | throw <| .userError "Bad size"
let some init := String.toNat? args[1]! | throw <| .userError "Bad init"
let some value := String.toNat? args[2]! | throw <| .userError "Bad value"
let arr := (Array.replicate size init).markLinear
let (arr, _) ← StateRefT'.run (pushIt value) arr
IO.println s!"{arr}"$ LEAN_ABORT_ON_NONLINEAR=1 lake exe linear 1 2 3
INTERNAL PANIC: array marked by `Array.markLinear` was used non-linearly
One important distinction between most sanitizers and the implementation of markLinear is
that calling markLinear comes at basically no execution time cost to the program. For this reason,
developers can liberally apply markLinear across their codebase without any drawbacks.
Then once it comes time to look for a linearity bug, users can enable LEAN_ABORT_ON_NONLINEAR
and profit from all the already installed annotations.
While this can help us identify that a linearity issue is present, e.g. during large-scale runs of
test suites, it does not yet help identify where the linearity issue occurs in the code. For
identifying the precise locations, it helps to first compile our code with debug symbols enabled.
This can be done with a simple modification of lakefile.toml and a rebuild:
name = "linear"
version = "0.1.0"
defaultTargets = ["linear"]
[[lean_exe]]
name = "linear"
root = "Main"
+ moreLeancArgs = ["-g3"]
Then we can run the program with a debugger and break on lean_internal_panic to catch the abort:
$ LEAN_ABORT_ON_NONLINEAR=1 gdb --args ./.lake/build/bin/linear 1 2 3
(gdb) b lean_internal_panic
Breakpoint 1 at 0x1943c0
(gdb) run
Starting program: /linear/.lake/build/bin/linear 1 2 3
Thread 3 "linear" hit Breakpoint 1, 0x00005555556e83c0 in lean_internal_panic ()
(gdb) bt
#0 0x00005555556e83c0 in lean_internal_panic ()
#1 0x00005555556e7141 in lean_copy_expand_array_nonlinear ()
#2 0x00005555556e718c in lean_array_push ()
#3 0x00005555555f7e01 in lp_linear_pushIt (v_n_1_=<optimized out>, v_a_2_=<optimized out>) at /linear/.lake/build/ir/Main.c:56
#4 _lean_main (v_args_40_=<optimized out>) at /linear/.lake/build/ir/Main.c:200
#5 0x00005555556d7d27 in std::__1::__function::__func<lean_run_main::$_0, void ()>::operator()[abi:nqe230100]() ()
#6 0x00005555556d857a in lean::lthread::imp::_main(void*) ()
#7 0x00007ffff7e14d19 in start_thread (arg=<optimized out>) at pthread_create.c:454
#8 0x00007ffff7e9864c in __GI___clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78
As we can see, we are now stuck in the lean runtime, but a piece of our own code, pushIt, is on the
call stack as well; let's inspect it:
(gdb) up 3
#3 0x00005555555f7e01 in lp_linear_pushIt (v_n_1_=<optimized out>, v_a_2_=<optimized out>) at /linear/.lake/build/ir/Main.c:56
56 v___x_5_ = lean_array_push(v___x_4_, v_n_1_);
(gdb) list
51 LEAN_EXPORT lean_object* lp_linear_pushIt(lean_object* v_n_1_, lean_object* v_a_2_){
52 _start:
53 {
54 lean_object* v___x_4_; lean_object* v___x_5_; lean_object* v___x_6_; lean_object* v___x_7_; lean_object* v___x_8_;
55 v___x_4_ = lean_st_ref_get(v_a_2_);
56 v___x_5_ = lean_array_push(v___x_4_, v_n_1_);
57 v___x_6_ = lean_st_ref_swap(v_a_2_, v___x_5_);
58 lean_dec(v___x_6_);
59 v___x_7_ = lean_box(0);
60 v___x_8_ = lean_alloc_ctor(0, 1, 0);
Unfortunately, no source-level Lean debugger exists yet, so this, together with the views we can get into the compilation pipeline, will have to suffice for identifying the location of the non-linearity:
abbrev M := StateRefT (Array Nat) IO
set_option trace.Compiler.saveBase true in
set_option trace.Compiler.saveMono true in
set_option trace.Compiler.saveImpure true in
def pushIt (n : Nat) : M Unit := do
let xs ← get
let ys := xs.push n
set ys[Compiler.saveBase] size: 8
def pushIt n @&a a.1 : EST.Out IO.Error lcAny PUnit :=
let _x.2 := @ST.Prim.Ref.get _ _ a a.1;
cases _x.2 : EST.Out IO.Error lcAny PUnit
| ST.Out.mk val.3 state.4 =>
let _x.5 := @Array.push _ val.3 n;
let _x.6 := @ST.Prim.Ref.set _ _ a _x.5 state.4;
cases _x.6 : EST.Out IO.Error lcAny PUnit
| ST.Out.mk val.7 state.8 =>
let _x.9 := @EST.Out.ok _ _ _ val.7 state.8;
return _x.9
[Compiler.saveMono] size: 8
def pushIt n @&a a.1 : EST.Out IO.Error lcAny PUnit :=
let _x.2 := @ST.Prim.Ref.get ◾ ◾ a a.1;
cases _x.2 : EST.Out IO.Error lcAny PUnit
| ST.Out.mk val.3 state.4 =>
let _x.5 := Array.push ◾ val.3 n;
let _x.6 := @ST.Prim.Ref.set ◾ ◾ a _x.5 state.4;
cases _x.6 : EST.Out IO.Error lcAny PUnit
| ST.Out.mk val.7 state.8 =>
let _x.9 := @EST.Out.ok ◾ ◾ ◾ val.7 state.8;
return _x.9
[Compiler.saveImpure] size: 4
def pushIt n @&a a.1 : obj :=
let _x.2 := ST.Prim.Ref.get ◾ ◾ a ◾;
let _x.3 := Array.push ◾ _x.2 n;
let _x.4 := ST.Prim.Ref.set ◾ ◾ a _x.3 ◾;
let _x.5 := ctor_0[EST.Out.ok] _x.4;
return _x.5
If we squint a bit, we can see that the reported location of the non-linearity at lean_array_push
corresponds to our xs.push n call from the original code, which is indeed correct.
Instead of aborting, we can also employ conditional breakpoints to break on the copy-on-write function for arrays whenever a marked array passes through it:
(gdb) break *lean_copy_expand_array_nonlinear if (((lean_object*)$rdi)->m_other & 0x80) != 0
# $rdi is the register in which lean_copy_expand_array_nonlinear receives the array
# `m_other & 0x80` is where the lean runtime stores the `markLinear` flag
Breakpoint 1 at 0x193100
(gdb) run
Thread 3 "linear" hit Breakpoint 1, 0x00005555556e7100 in lean_copy_expand_array_nonlinear ()
(gdb) bt
#0 0x00005555556e7100 in lean_copy_expand_array_nonlinear ()
#1 0x00005555556e718c in lean_array_push ()
#2 0x00005555555f7e01 in lp_linear_pushIt (v_n_1_=<optimized out>, v_a_2_=<optimized out>) at /linear/.lake/build/ir/Main.c:56
#3 _lean_main (v_args_40_=<optimized out>) at /linear/.lake/build/ir/Main.c:200
#4 0x00005555556d7d27 in std::__1::__function::__func<lean_run_main::$_0, void ()>::operator()[abi:nqe230100]() ()
#5 0x00005555556d857a in lean::lthread::imp::_main(void*) ()
#6 0x00007ffff7e14d19 in start_thread (arg=<optimized out>) at pthread_create.c:454
#7 0x00007ffff7e9864c in __GI___clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78
If LEAN_ABORT_ON_NONLINEAR is not set, continuing the program at this point will break again at
the next linearity issue and so on, allowing us to collect statistics about all the issues
present in a program. This can, for example, be used to prioritize linearity issues in preexisting
large code bases.
Conclusion
In addition to Array.markLinear the standard library ships the same function on String,
Std.HashSet, Std.HashMap, Std.DHashMap, ByteArray, FloatArray, and Vector which are by far the most
common linear data structures in use today. I hope that these functions together with the above
debugging recipes will allow us to detect, identify and fix linearity issues in a more principled
way as opposed to just looking at the generated code until we realize why they are present in the
future. It has already started helping Lean itself.