@assert is surprisingly expensive / outlining rare paths

260 views
Skip to first unread message

Rauli Ruohonen

unread,
Nov 6, 2013, 1:04:37 PM11/6/13
to juli...@googlegroups.com
Consider this function I made up to get a small example (at least it's not fibonacci...):

function dabs1(x)
    if x >= 0
        @assert x != 0
        return 1
    end
    -1
end

Call it an error-conscious derivative of abs(x). How costly is that innocuous-looking @assert in the usual case, where x != 0? Just one comparison and a jump, or something of the kind? No.

On my machine I get all these instructions until the comparison op (git head):

        push    RBP
        mov     RBP, RSP
        push    R14
        push    RBX
        sub     RSP, 32
        mov     QWORD PTR [RBP - 48], 4
        movabs  RAX, 140522660913808
        mov     RCX, QWORD PTR [RAX]
        mov     QWORD PTR [RBP - 40], RCX
        lea     RCX, QWORD PTR [RBP - 48]
        mov     QWORD PTR [RAX], RCX
        xorps   XMM0, XMM0
        movups  XMMWORD PTR [RBP - 32], XMM0
        test    RDI, RDI

That's a lot of overhead for such a simple function! Now compare this:

macro myassert(ex)
    :($ex ? nothing : throw($(ErrorException("Assertion failed: "*string(ex)))))
end

function dabs2(x)
    if x >= 0
        @myassert x != 0
        return 1
    end
    -1
end

The instructions until the comparison op:

        push    RBP
        mov     RBP, RSP
        mov     RAX, -1
        test    RDI, RDI

That's more reasonable. Comparing performance using @time for both:

dabs1: elapsed time: 0.04728299 seconds (73580 bytes allocated)
dabs2: elapsed time: 0.000739985 seconds (11564 bytes allocated)

The standard @assert results in a function that's 64x slower than @myassert (for this run, it really varies quite a lot). Would @myassert make a workable drop-in replacement for @assert, or would something break?

I'm also a little curious as to why @assert incurs such an overhead. Are all those instructions constructing and tearing down parts of a shadow stack or something? In most languages, when you do "if some_rare_event; some_expensive_computation end", you don't pay any cost for the expensive stuff except rarely. Here Julia seems to move work out of the rare path onto the common path, which is a pessimization. Manually outlining rare stuff helps:

function rare_path(x)
    @assert x != 0
end

function dabs3(x)
    if x >= 0
        if x != 0
            return 1
        end
        rare_path()
    end
    -1
end

This is pretty much equal to using @myassert (both performance and asm). Aside from any tweaks to the @assert macro, is this type of outlining something that the compiler should do automatically? Or let the user hint the compiler about it somehow? E.g. if a macro could somehow say "put this stuff in a separate function and return a reference to that function to me please", it would be simple to use in debugging macros even when the computation is not a compile-time constant, or even make macros like "@outline begin <stuff> end" and "@unlikely_if <condition> begin <stuff to be outlined> end".

Ariel Keselman

unread,
Nov 10, 2013, 4:09:47 AM11/10/13
to juli...@googlegroups.com
My tests show the same timing for dabs1 and dabs2. Maybe this issue was fixed. How did you measure exactly?

That being said, standard the assert macro is defined in error.jl starting line 43:

macro assert(ex,msg...)
    msg
= isempty(msg) ? :(string($(Expr(:quote,ex)))) : esc(msg[1])
   
:($(esc(ex)) ? nothing : error("assertion failed: ", $msg))
end

This is a bit different than your my_assert, but I can't measure any performance difference between them, so seems it is optimized to the following:

macro massert(ex,msg...)
   
:($(esc(ex)) ? nothing : error("assertion failed: ", $(isempty(msg) ? :(string($(Expr(:quote,ex)))) : esc(msg[1]))))
end

where only the slow path is still a bit different. I didn't check the code_native, though

Ariel.

Rauli Ruohonen

unread,
Nov 12, 2013, 3:30:41 AM11/12/13
to juli...@googlegroups.com
Essentially, I was playing with code_native(), being curious what kind of code Julia generates for easy to optimize stuff. The extra code generated by @assert was a surprise, and seemed like it ought to cause significant overhead. The timings were pretty much an afterthought, and I did them simply with @time.

Now that I tried timing it a bit more seriously, I found that it's difficult. The variances in timing something as fast as this are enormous relative to the average time. Julia also does more inlining than I thought, so in practice the extra instructions may not matter too much after all.

FWIW, this version of myassert is closer to the standard one, and still produces short assembly:

macro myassert(ex,msg...)
  msg
= isempty(msg) ? string(ex) : esc(msg[1])
 
:($(esc(ex)) ? nothing : _assert($msg))
end

function _assert(x) # @noinline would be nice
  error
("assertion failed: ", x)
  nothing
end



The modifications are:

1. Turn ex into a string compile-time instead of run-time.

2. De-inline two-argument call to error(). One-argument call is less expensive, maybe because error() uses varargs for the multiargument case?

3. Return "nothing" after the error call. This is also required; I think this just prevents Julia from inlining _assert.

Jeff Bezanson

unread,
Nov 14, 2013, 3:47:59 PM11/14/13
to juli...@googlegroups.com
Thanks for discovering this. Should be fixed on master now.
Reply all
Reply to author
Forward
0 new messages