😤 Multi-line expressions aren't fully computed

I had a KernelAbstractions.jl based Kernel function with a multi-line expression as below:

using KernelAbstractions

@kernel function karniadakis_update!(
    n_eₜ₊₁, n_eₜ, n_eₜ₋₁, n_eₜ₋₂, e_rhsₜ, e_rhsₜ₋₁, e_rhsₜ₋₂, 
    ν_perp, ∇⁴nₑ
)
    i, j = @index(Global, NTuple)
    
    if i <= size(n_eₜ, 1) && j <= size(n_eₜ, 2)
        # Karniadakis coefficients
        cₜ, cₜ₋₁, cₜ₋₂ = 18/11, 9/11, 2/11
        cᵣₕₛ = 6/11
        
        # Electron update
        n_eₜ₊₁[i, j] = cₜ * n_eₜ[i, j] - cₜ₋₁ * n_eₜ₋₁[i, j] + cₜ₋₂ * n_eₜ₋₂[i, j]   
                           + Δt * cᵣₕₛ * (3 * e_rhsₜ[i, j] - 3 * e_rhsₜ₋₁[i, j] + e_rhsₜ₋₂[i, j])
                           - Δt * cᵣₕₛ * ν_perp * ∇⁴nₑ[i, j]
    end
end

and it seems to only compute the first line for some reason, i had buggy code for weeks because of this and I only found it by comparing the implementation to a CPU implementation with regular for loops. Wasted far too much time looking for bugs in my codebase when this was the issue. :face_with_steam_from_nose:

Doing it instead the following way seems to fix it:

using KernelAbstractions

@kernel function karniadakis_update!(
    n_eₜ₊₁, n_eₜ, n_eₜ₋₁, n_eₜ₋₂, e_rhsₜ, e_rhsₜ₋₁, e_rhsₜ₋₂, 
    ν_perp, ∇⁴nₑ
)
    i, j = @index(Global, NTuple)
    
    if i <= size(n_eₜ, 1) && j <= size(n_eₜ, 2)
        # Karniadakis coefficients
        cₜ, cₜ₋₁, cₜ₋₂ = 18/11, 9/11, 2/11
        cᵣₕₛ = 6/11
        
        # Electron update
        n_eₜ₊₁[i, j] = cₜ * n_eₜ[i, j] - cₜ₋₁ * n_eₜ₋₁[i, j] + cₜ₋₂ * n_eₜ₋₂[i, j]   
        n_eₜ₊₁[i, j] += Δt * cᵣₕₛ * (3 * e_rhsₜ[i, j] - 3 * e_rhsₜ₋₁[i, j] + e_rhsₜ₋₂[i, j])
        n_eₜ₊₁[i, j] -= Δt * cᵣₕₛ * ν_perp * ∇⁴nₑ[i, j]
    end
end

Is this a well known bug? I hope they fix it soon, because this is the kind of thing one won’t see easily at all.

There’s no bug, there’s only a misunderstanding of how line continuation works. Also, this has nothing to do with KernelAbstractions.jl, it’s just how parsing works in any language, not just Julia. The problem is that you have an expression like

a = b
    + c

but the expression

a = b

is complete and parsing ends at the end of the line, then you have the separate expression

+ c

which is executed on its own, but it’s also probably completely elided by the compiler in practice since it doesn’t do anything (it’s not assigned to any variable, it doesn’t have side effects).

If you want to split your expression over multiple lines you either make the expression at the of a line incomplete (for example with a trailing binary operator) to let parsing continue in the following line

a = b +
    c

or use parentheses (which is another way of making the expression incomplete: it’s waiting for the closing bracket)

a = (b
     + c)

Just to show that this isn’t Julia-specific, you’d have the same issue also in Python:

In [1]: def fun(b, c):
   ...:     a = b
   ...:     + c
   ...:     return a
   ...: 

In [2]: fun(2, 3)
Out[2]: 2

In [3]: fun(5, 7)
Out[3]: 5

Ok, Python saves you only because of significant whitespaces, if you indent the line as you’d expect then you’d get an error, but that’s beside the point.

14 Likes

okay, my reason for keeping the + or - at the beginning of the line was for ease of checking for sign errors.
I did not know this was intended behavior.

thanks for the answer.

If you want to keep signs at the beginning of the line, and it’s always + or -, you could also do

a = b +
    + c +
    - d

which still follows the idea of “making the expression incomplete, to continue parsing in the following line”. But honestly I think that that parentheses are more robust in this case: you don’t need to keep a trailing + in all lines, which is just another source of error.

3 Likes

yeah, probably parentheses or using += are the right approaches here.
The issue persists in regular Julia as well:

a, b, c , d, e = 0, 2, 3, 4, 5
a = b + c + d
    + e
println("a = $a")

this prints a = 9 instead of a = 14 which I would have expected from my misunderstanding.

However, shouldn’t Julia warn of this? at the very least, shouldn’t the Julia extension underline this as a potential issue?

because as far as the compiler is concerned it looks at the expression

a = b + c + d

which it computes, but then it looks at the following line with

    + e

it just ignores it and does not even return a warning.

Can’t that be considered a bug?

What bug? I even tried to show this isn’t specific to Julia.

maybe bug is too strong a word for this. Isn’t this something for which a warning would be nice?

That’s something for a linter to do, but from a purely syntactical point of view you I don’t think there’s anything wrong.

One might say that mandating the semicolon to terminate all expressions would avoid this, but the time for arguing this is long past, I don’t think such a breaking change would ever happen at this point.

1 Like

To add on this, from a purely syntactical point of view, that line might be doing something:

julia> mutable struct MyStruct
           x::Int
       end

julia> e = MyStruct(0)
MyStruct(0)

julia> Base.:+(e::MyStruct) = e.x += 1

julia> + e
1

julia> + e
2

julia> e
MyStruct(2)

This is a bit of a contrived example, but wanted to show that just by reading the line without any runtime context you can’t say “this is not doing anything”.

5 Likes

Since this is valid syntax, any detection mechanism that tries to check for (and warn about) this kind of thing would have to be heuristics based - like checking for lines beginning with a + or -, maybe checking the indentation, etc. Any such check would be prone to have a lot of false positives and negatives, so the Julia compiler wouldn’t be the right place for it. It would be way too annoying to have the ocmpiler warn about such code in an hard-to-predict (because of the heuristics) way when the code is valid.

That said,

it would be nice to have it in some way as part of the extension. A warning for this was proposed as a github issue and rejected several years ago because it’s valid Julia syntax, but the Rust developer experience is so good precisely because tools like clippy warn about code even when it’s technically correct, if it’s a pattern that a lot of people make mistakes with. So if you can think of some good heuristics to detect this kind of code, it might be worth bringing it up in the Slack vscode channel.

(Speaking of tools, do you use anything like the JuliaFormatter or Runic.jl ? I would think those would reformat this so the latter lines of the expression don’t have an indent, hinting at the issue even if they don’t directly explain it. )

4 Likes

The other issue is that this could be an effectful line depending on the type. Consider the following (ill-advised) code

julia> struct T end

julia> Base.:+(::T) = println("hello, world")

julia> function f(a, b, c)
           x = a + b
               + c
           return x
           end
f (generic function with 1 method)

julia> f(1,2,T())
hello, world
3

I’m wondering if this prefix operators are actually needed though

+(x::Number) = x
*(x::Number) = x
(&)(x::Integer) = x
(|)(x::Integer) = x
xor(x::Integer) = x

As of now I can’t think of cases where they could be needed, and eliminating them would result in a MethodError. Could it be the case to open a different thread to actually evaluate the role of these methods? Or is there a trivial reason for them to exist that I’m not considering?

Yes, there is:

1 Like

Those are unary methods, not exactly infix which by definition are binary. You can’t use * as a unary operator.

2 Likes

I’m sorry I meant prefix, I change it.

Ok thank you. Just to summarize eliminating a method would result in a breaking change and breaking is allowed only in a major release. Still, if you know if they were added with some use case in mind I’d appreciate if you could write it, just out of curiosity.

As I said above you can’t use * as a unary operator:

julia> * 2
ERROR: ParseError:
# Error @ REPL[4]:1:1
* 2
╙ ── not a unary operator
Stacktrace:
 [1] top-level scope
   @ none:1

You can use a regular function call, with parentheses:

julia> *(2)
2
2 Likes

People often use unary + to be symmetrical with -. Some examples: Code search results · GitHub and

1 Like

As already pointed out those definitions are not prefix operators at all but the one-argument methods of functions taking any number of arguments. The one argument methods are maybe not used all that much in practice but it would be surprising if they errored instead of returning the natural result.

Whether there should be prefix syntax for unary + is a different question but all languages I know about allow you to write

x = 1
y = +x

so it seems like people have found this to be a good idea.

2 Likes

Vararg * is used to implement prod of tuples:

Not being able to do prod((n,)), especially in generic code where you don’t know the length of input arguments would be very unfortunate.

2 Likes

Thank you all for the explanations, I think my main misunderstanding was in the difference between the functions acting on a single variable and the infix operators. Unfortunately as self taught it is easy to miss something, so thank you for the patience and the explanation :grinning_face_with_smiling_eyes:

3 Likes