Skip to content

Commit

Permalink
Update docs for new @check_allocs macro
Browse files Browse the repository at this point in the history
  • Loading branch information
topolarity committed Nov 17, 2023
1 parent 502f23e commit 773c64e
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 31 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ julia> length(check_allocs(sin, (Float64,); ignore_throw=true)) # ignore allocat

#### Limitations

1. Runtime dispatch
Every call into a `@check_allocs` function behaves like a dynamic dispatch. This means that it can trigger compilation dynamically (involving lots of allocation), and even when the function has already been compiled, a small amount of allocation is still expected on function entry.

Any runtime dispatch is conservatively assumed to allocate.
For most applications, the solution is to use `@check_allocs` to wrap your top-level entry point or your main application loop, in which case those applications are only incurred once. `@check_allocs` will guarantee that no dynamic compilation or allocation occurs once your function has started running.
6 changes: 5 additions & 1 deletion docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@

```@docs
AllocCheck.check_allocs
```
```

```@docs
AllocCheck.@check_allocs
```
47 changes: 29 additions & 18 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,58 @@ AllocCheck operates on _functions_, trying to statically determine wether or not

## Getting started

The main entry point to check allocations is the function [`check_allocs`](@ref), which takes the function to check as the first argument, and a tuple of argument types as the second argument:
```@example README
The primary entry point to check allocations is the macro [`@check_allocs`](@ref) which is used to annotate a function definition that you'd like to enforce allocation checks for:
```@repl README
using AllocCheck
mymod(x) = mod(x, 2.5)
using Test # hide
@check_allocs mymod(x) = mod(x, 2.5)
check_allocs(mymod, (Float64,))
mymod(1.5) # call automatically checked for allocations
```
This returned an empty array, indicating that the function was proven to not allocate any memory 🎉
This call happened without error, indicating that the function was proven to not allocate any memory after it starts 🎉


When used on a function that may allocate memory
```@example README
linsolve(a, b) = a \ b
```@repl README
@check_allocs linsolve(a, b) = a \ b
allocs = check_allocs(linsolve, (Matrix{Float64}, Vector{Float64}));
length(allocs)
linsolve(rand(10,10), rand(10))
```
we get a non-empty array of allocation instances. Each allocation instance contains some useful information, for example
the function call raises an `AllocCheckFailure`.

The `errors` field allows us to inspect the individual errors to get some useful information. For example:

```@example README
allocs[1]
try
linsolve(rand(10,10), rand(10))
catch err
err.allocs[1]
end
```

we see what type of object was allocated, and where in the code the allocation appeared.


### Functions that throw exceptions

Some functions that we do not expect may allocate memory, like `sin`, actually may:
```@example README
length(check_allocs(sin, (Float64,)))
@allocated try sin(Inf) catch end
```
The reason for this is that `sin` may **throw an error**, and the exception path requires some allocations. We can ignore allocations that only happen when throwing errors by passing `ignore_throw=true`:

The reason for this is that `sin` needs to allocate if it **throws an error**.

By default, `@check_allocs` ignores all such allocations and assumes that no exceptions are thrown. If you care about detecting these allocations anyway, you can use `ignore_throw=false`:
```@example README
allocs = check_allocs(sin, (Float64,); ignore_throw=true) # ignore allocations that only happen when throwing errors
@check_allocs mysin1(x) = sin(x)
@check_allocs ignore_throw=false mysin2(x) = sin(x)
using Test
@test isempty(allocs)
@test mysin1(1.5) == sin(1.5)
@test_throws AllocCheckFailure mysin2(1.5)
```

## Limitations

1. Runtime dispatch
Any runtime dispatch is conservatively assumed to allocate.
Every call into a `@check_allocs` function behaves like a dynamic dispatch. This means that it can trigger compilation dynamically (involving lots of allocation), and even when the function has already been compiled, a small amount of allocation is still expected on function entry.

For most applications, the solution is to use `@check_allocs` to wrap your top-level entry point or your main application loop, in which case those applications are only incurred once. `@check_allocs` will guarantee that no dynamic compilation or allocation occurs once your function has started running.
36 changes: 27 additions & 9 deletions docs/src/tutorials/optional_debugging_and_logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

For debugging purposes, it may sometimes be beneficial to include logging statements in a function, for example
```@example DEBUGGING
function myfun(verbose::Bool)
using AllocCheck # hide
@check_allocs function myfun(verbose::Bool)
a = 0.0
for i = 1:3
a = a + i
Expand All @@ -12,9 +13,8 @@ end
nothing # hide
```
Here, the printing of some relevant information is only performed if `verbose = true`. While the printing is optional, and not performed if `verbose = false`, [`check_allocs`](@ref) operates on _types rather than values_, i.e., `check_allocs` only knows that the argument is of type `Bool`, not that it may have the value `false`:
```@example DEBUGGING
using AllocCheck
check_allocs(myfun, (Bool,)) |> length
```@repl DEBUGGING
myfun(false)
```
Indeed, this function was determined to potentially allocate memory.

Expand All @@ -28,7 +28,7 @@ function typed_myfun(::Val{verbose}) where verbose
end
end
check_allocs(typed_myfun, (Val{false},)) |> length
length(check_allocs(typed_myfun, (Val{false},)))
```

The compiler, and thus also AllocCheck, now knows that the value of `verbose` is `false`, since this is encoded in the _type_ `Val{false}`. The compiler can use this knowledge to figure out that the `@info` statement won't be executed, and thus prove that the function will not allocate memory.
Expand All @@ -40,9 +40,27 @@ typed_myfun(Val{true}())


## Advanced: Constant propagation
Sometimes, the compiler is able to use _constant propagation_ to determine what path through a program will be taken based on the _value of constants_. We demonstrate this effect below, where the value `verbose = false` is hard-coded

Sometimes, code written without this trick will still work just fine with AllocCheck.

That's because in some limited scenarios, the compiler is able to use _constant propagation_ to determine what path through a program will be taken based on the _value of constants_.

We demonstrate this effect below, where the value `verbose = false` is hard-coded into the function:
```@example DEBUGGING
my_outer_function() = myfun(false) # Hard coded value false
check_allocs(my_outer_function, ()) |> length
@check_allocs function constant_myfun()
verbose = false
a = 0.0
for i = 1:3
a = a + i
verbose && @info "a = $a"
end
return a
end
constant_myfun()
```
When looking at `my_outer_function`, the compiler knows that `verbose = false` since this constant is hard coded into the program, and the compiler thus has the same amount of information here as when the value was lifted into the type domain. Constant propagation is considered a performance optimization that the compiler may or may not perform, and it is thus recommended to use the `Val` type to lift values into the type domain to guarantee that the compiler will use this information.

When looking at `constant_myfun`, the compiler knows that `verbose = false` since this constant is hard coded into the program. Sometimes, the compiler can even propagate constant values all the way into called functions.

This is useful, but it's not guaranteed to happen in general. The `Val{T}` trick described here ensures that the variable is propagated as a constant everywhere it is required.

2 changes: 1 addition & 1 deletion src/AllocCheck.jl
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ function find_allocs!(mod::LLVM.Module, meta; ignore_throw=true)
end

"""
check_allocs(func, types; entry_abi=:specfunc, ret_mod=false)
check_allocs(func, types; ignore_throw=true)
Compiles the given function and types to LLVM IR and checks for allocations.
Returns a vector of `AllocationSite` structs, each containing a `CallInst` and a backtrace.
Expand Down
33 changes: 33 additions & 0 deletions src/macro.jl
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ function extract_keywords(ex0)
return kws, arg
end

"""
@check_allocs ignore_throw=true (function def)
Wraps the provided function definition so that all calls to it will be automatically
checked for allocations.
If the check fails, an `AllocCheckFailure` exception is thrown containing the detailed
failures, including the backtrace for each defect.
Note: All calls to the wrapped function are effectively a dynamic dispatch, which
means they are type-unstable and may allocate memory at function _entry_. `@check_allocs`
only guarantees the absence of allocations after the function has started running.
# Example
```jldoctest
julia> @check_allocs multiply(x,y) = x*y
multiply (generic function with 1 method)
julia> multiply(1.5, 3.5) # no allocations for Float64
5.25
julia> multiply(rand(3,3), rand(3,3)) # matmul needs to allocate the result
ERROR: @check_alloc function contains 1 allocations.
Stacktrace:
[1] macro expansion
@ ~/repos/AllocCheck/src/macro.jl:134 [inlined]
[2] multiply(x::Matrix{Float64}, y::Matrix{Float64})
@ Main ./REPL[2]:133
[3] top-level scope
@ REPL[5]:1
```
"""
macro check_allocs(ex...)
kws, body = extract_keywords(ex)
if _is_func_def(body)
Expand Down

0 comments on commit 773c64e

Please sign in to comment.