AllocCheck.jl

repository·main·Indexed 42 days ago

https://github.com/julialang/alloccheck.jl

A Julia package for static analysis of LLVM IR to determine if a function may allocate memory. It provides the `@check_allocs` macro to detect potential allocations before runtime, helping developers write allocation-free code for high-performance and real-time systems. It includes tools to inspect `AllocCheckFailure` exceptions and configure checks to include or ignore allocations on exception paths using the `ignore_throw` argument.

Tokens
3.1K
Snippets
10
Records
11
Agent score
54%

What's inside AllocCheck.jl

  1. How constant propagation affects AllocCheck

    main

    In some scenarios, check_allocs can successfully prove zero allocations without using Val{T} if the compiler can use constant propagation. This happens when a value is hard-coded as a constant within the function, allowing the compiler to determine which code paths are taken.

    While constant propagation works for simple hard-coded values, it is not guaranteed to happen in all complex scenarios. Using the Val{T} pattern is the reliable way to ensure a value is treated as a constant throughout the function's execution.

    @check_allocs function constant_myfun()
        verbose = false # Hard-coded constant
        verbose && @info "log"
        return 0
    end
    
    constant_myfun() # May work via constant propagation
  2. Understand the limitations of @check_allocs

    main

    Every call into a function annotated with @check_allocs behaves like a dynamic dispatch. This has two implications:

    1. It can trigger dynamic compilation (which involves significant allocation).
    2. Even for already-compiled functions, a small amount of allocation is expected at the function entry.

    Best Practice: To minimize the impact of these entry-level allocations, use @check_allocs to wrap your top-level entry point or your main application loop. This ensures that the overhead is incurred only once, and the macro will guarantee no dynamic compilation or allocation occurs once the main logic starts running.

  3. Using a Workspace struct for complex hot loop initialization

    main

    When a hot loop requires multiple distinct allocations or complex types (like closures or multiple arrays), it is best practice to encapsulate these in a Workspace struct. This allows you to separate the setup logic from the execution logic.

    By using a struct, you can easily determine the exact type required for check_allocs by calling typeof(workspace) after the setup phase. Even if the struct itself is immutable, it can contain mutable fields (like Vector or Matrix) that the hot loop modifies in-place.

    Workflow:

    1. Define a Workspace struct containing the necessary buffers.
    2. Create a setup() function that returns an initialized Workspace.
    3. Create a run!(workspace) function containing the hot loop.
    4. Use check_allocs(run!, (typeof(workspace),)) to validate the loop.
    struct Workspace
        cache1::Vector{Float64}
        cache2::Matrix{Float64}
    end
    
    function setup()
        return Workspace(zeros(100), zeros(100, 100))
    end
    
    function run!(workspace::Workspace)
        for i = eachindex(workspace.cache1)
            workspace.cache1[i] = 1.0
        end
    end
    
    # Verification
    workspace = setup()
    allocs = check_allocs(run!, (typeof(workspace),))
    @test isempty(allocs)
  4. How to check for allocations in a hot loop

    main

    In high-performance or real-time Julia code, a common pattern is to perform all necessary memory allocations in a 'preamble' phase, followed by a performance-sensitive 'hot loop' that must perform zero allocations to ensure predictable timing and avoid garbage collection.

    To use AllocCheck.jl effectively for this pattern, you should refactor your code into two distinct parts:

    1. A Preamble: A function that performs the initial allocations (e.g., creating vectors, matrices, or workspace structs).
    2. A Hot Loop: A function (conventionally ending in ! to indicate in-place modification) that accepts the pre-allocated memory as arguments and performs the core computation without allocating.

    You can then use check_allocs to verify that the hot loop function is indeed allocation-free.

    using AllocCheck, Test
    
    # 1. Define the hot loop that uses pre-allocated memory
    function run_hot_loop!(logvector)
        for i = eachindex(logvector)
            @inbounds logvector[i] = 2.0 # Example computation
        end
    end
    
    # 2. Use check_allocs to verify it performs no allocations
    # The second argument is a tuple of the types of the arguments passed to the function
    allocs = check_allocs(run_hot_loop!, (Vector{Float64},))
    @test isempty(allocs)
  5. Ensure guaranteed error recovery in real-time systems

    main

    In safety-critical real-time systems, error-recovery logic must run with minimum latency. By default, check_allocs allows allocations that occur on the error path (allocations triggered by an exception). These allocations can trigger the Garbage Collector (GC), introducing unbounded latency before your recovery logic executes.

    To guarantee low-latency recovery, follow this pattern:

    1. Verify zero allocations: Use check_allocs to prove the function does not allocate memory except on exception paths.
    2. Disable the GC: Manually disable the GC (GC.enable(false)) before entering the critical section.
    3. Recover and Re-enable: Use a try...catch...finally block. Execute your recovery logic in the catch block and ensure the GC is re-enabled in the finally block to prevent system-wide issues.
    function treading_lightly()
        a = 0.0
        GC.enable(false) # Turn off the GC before entering the loop
        try
            for i = 10:-1:-1
                a += sqrt(i) # This throws an error for negative values of i
            end
        catch
            exit_gracefully() # This function is supposed to run with minimum latency
        finally
            GC.enable(true) # Always turn the GC back on before exiting the function
        end
        a
    end
    
    exit_gracefully() = println("Calling mother")
    
    using AllocCheck, Test
    # Verify that the function does not allocate memory on the happy path
    allocs = check_allocs(treading_lightly, ())
    @test isempty(allocs)
  6. Use the @check_allocs macro to detect static allocations

    main

    AllocCheck.jl provides the @check_allocs macro to statically check if a function call may allocate memory by analyzing its LLVM IR. Unlike @allocated, which measures runtime allocations, @check_allocs attempts to prove whether a function can allocate.

    When you annotate a function with @check_allocs, calling that function will automatically trigger a check. If an allocation is detected, the function will raise an AllocCheckFailure exception.

    using AllocCheck
    
    # Annotate a function to enforce allocation checks
    @check_allocs multiply(x, y) = x * y
    
    # This call is proven not to allocate and succeeds
    multiply(1.5, 2.5)
    
    # This call triggers an allocation and raises an error
    multiply(rand(3, 3), rand(3, 3))
  7. Use Val{T} to prove zero-allocation for optional logging

    main

    Because check_allocs operates on types rather than values, it cannot know that a boolean argument like verbose::Bool will be false at runtime. If your function contains conditional logging (e.g., verbose && @info ...), check_allocs may report potential allocations even if the logging is never actually executed.

    To prove a function does not allocate when logging is disabled, you must lift the value into the type domain using the Val{T} type. This encodes the value into the function's signature, allowing the compiler and check_allocs to see that the logging path is unreachable.

    # 1. The problematic way: check_allocs sees 'Bool' and assumes potential allocation
    @check_allocs function myfun(verbose::Bool)
        verbose && @info "log"
    end
    myfun(false) # May report allocations
    
    # 2. The correct way: use Val{T} to encode the value in the type
    function typed_myfun(::Val{verbose}) where verbose
        verbose && @info "log"
    end
    
    # Prove it allocates 0 bytes when verbose is false
    length(check_allocs(typed_myfun, (Val{false},)))
    
    # You can still use it with logging enabled:
    typed_myfun(Val{true}())
  8. Configure @check_allocs to detect allocations during exceptions

    main

    By default, @check_allocs ignores allocations that occur only when a function throws an error (e.g., sin(Inf) allocating memory to handle the error). If you need to detect these specific allocations, use the ignore_throw=false keyword argument.

    # Default behavior: ignores allocations triggered by exceptions
    @check_allocs mysin1(x) = sin(x)
    
    # Strict behavior: detects allocations triggered by exceptions
    @check_allocs ignore_throw = false mysin2(x) = sin(x)
  9. Verify memory usage on exception paths with @allocated

    main

    If you accept that some allocation occurs on the exception path, you should verify that the amount of memory allocated is small enough that it won't trigger a GC cycle before your recovery logic completes. You can use the @allocated macro to measure the memory used during a call that triggers the exception path.

    treading_lightly() # Warm start
    # Measure memory allocated during a call that triggers the exception path
    allocated_memory = @allocated treading_lightly()
    # Example threshold check
    # @test allocated_memory < 1e4
  10. Inspect allocation errors with AllocCheckFailure

    main

    When an @check_allocs annotated function fails, it raises an AllocCheckFailure. You can catch this exception and inspect the errors field to identify exactly where the allocation occurred and view the associated stacktrace.

    try
        multiply(rand(3, 3), rand(3, 3))
    catch err
        # Access the specific error details
        err.errors[1]
    end
  11. Inspect allocations on exception paths using ignore_throw

    main

    To identify exactly what is being allocated when an error occurs, call check_allocs with the ignore_throw = false flag. This allows you to see allocations that only happen as a consequence of an exception being thrown. You can use the reported allocation sites as a guide for what to test or optimize to ensure your error recovery remains within latency bounds.

    # Check for allocations that occur specifically on the error path
    allocs = check_allocs(treading_lightly, (); ignore_throw = false)
    length(allocs)