Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Element-wise call syntax does not always lower to standard broadcast() #19313

Closed
nalimilan opened this issue Nov 13, 2016 · 32 comments · Fixed by #26891
Closed

Element-wise call syntax does not always lower to standard broadcast() #19313

nalimilan opened this issue Nov 13, 2016 · 32 comments · Fixed by #26891
Labels
broadcast Applying a function over a collection won't change Indicates that work won't continue on an issue or pull request

Comments

@nalimilan
Copy link
Member

It appears that some simple uses of the element-wise call syntax .( are not lowered to a standard broadcast call. This is a problem for NullableArray where we would like to overload broadcast to apply lifting semantics (JuliaStats/NullableArrays.jl#166).

For example, the following code doesn't call broadcast(get, X, 0). Therefore it fails since my custom broadcast function has a special behavior for get which doesn't get triggered here.

Why it this the case?

julia> expand(:(get.([Nullable()], 0)))
:($(Expr(:thunk, CodeInfo(:(begin 
        $(Expr(:thunk, CodeInfo(:(begin 
        global ##3#4
        const ##3#4
        $(Expr(:composite_type, Symbol("##3#4"), :((Core.svec)()), :((Core.svec)()), :(Core.Function), :((Core.svec)()), false, 0))
        return
    end))))
        $(Expr(:method, false, :((Core.svec)((Core.svec)(##3#4,Any),(Core.svec)())), CodeInfo(:(begin 
        return get(#temp#,0)
    end)), false))
        #3 = $(Expr(:new, Symbol("##3#4")))
        SSAValue(0) = #3
        SSAValue(1) = (Base.vect)(Nullable())
        return (Base.broadcast)(SSAValue(0),SSAValue(1))
    end)))))
@yuyichao
Copy link
Contributor

This is an optimization since broadcast(get, X, 0) shouldn't do anything other than what the special lowering does.

@yuyichao yuyichao added the won't change Indicates that work won't continue on an issue or pull request label Nov 13, 2016
@nalimilan
Copy link
Member Author

So that means one cannot specialize broadcast for a custom array type? Sounds quite restrictive to me. Is the expected behavior documented somewhere?

@nalimilan
Copy link
Member Author

To clarify, IIUC, get.(X, 0) gets lowered to broadcast(x -> get(x, 0), X) instead of broadcast(get, X, 0) for performance reasons. Since that's done at parse time, no dispatch on the input types is possible. I see the point of this optimization, but unfortunately it hardcodes the behavior of broadcast for any type in the parse, which is unusual for a Julia method which by definition can be overridden.

Maybe there should be a type check, and the fast code would only be used for Array and Base scalar types?

@martinholters
Copy link
Member

If you need to specialize broadcast for get, that is a bad sign anyway since it wouldn't work with fusion. E.g. neither get.(foo.(X), 0) nor get.(foo.(X), i) would end up calling your specialization.

@nalimilan
Copy link
Member Author

Yes, I've noticed that too. Whatever we try to improve the Nullable workflow, we bump against lack of support in Base. I'm starting to get tired of these repeated dead ends. See JuliaStats/NullableArrays.jl#166.

@stevengj
Copy link
Member

stevengj commented Nov 14, 2016

Yes, as I commented in #17623, the existence of parse-time fusion by itself (even without constant-folding optimizations) makes defining specialized broadcast methods for particular functions effectively useless.

On the plus side, this sometimes forces us to pursue more general solutions, e.g. for preserving sparsity for broadcast on sparse matrices.

@nalimilan
Copy link
Member Author

Right. Unfortunately, we can't just call the function to check whether it is zero-preserving or looks at its return type to decide whether to return a BitArray. Here, non-lifting functions really need to be identified and transformed before being fused with others. The generalization that this encourages is to lift all function calls involving Nullable by default, even outside broadcast.

@stevengj
Copy link
Member

@nalimilan, what is a "non-lifting" function and how would you identify one at parse-time?

@nalimilan
Copy link
Member Author

I don't think hard-coding them at parse time would be a good idea, even though their list should be pretty limited. Basically, these are functions which operate on the Nullable object itself rather than the value it wraps: isnull, get, plus logical operators isequal, isless, & and | (for three-valued logic). See JuliaStats/NullableArrays.jl#166 and JuliaStats/NullableArrays.jl#144.

@stevengj
Copy link
Member

I guess in principle we could transform:

f.(g.(x...)) ---> isfusing(g,x...) ? broadcast((_...) -> f(g(_...)), x...) : broadcast(f, broadcast(g, x...))

where isfusing would default to true. i.e. you allow the caller to define "fusion barriers" for particular functions. Or isfusing could automatically return false if a specialized broadcast method exists for g? I'm not sure if this is a good direction to pursue, however.

@stevengj
Copy link
Member

stevengj commented Nov 14, 2016

Note that if you want a non-fusing broadcast-like operation, you can just define a method without dots that acts on arrays. e.g. & and | will never be fusing. Similarly, you could just define get(a::NullableArray, ...) that acts like a get.(a, ...) but doesn't fuse.

@nalimilan
Copy link
Member Author

That would work. Though strictly speaking loops can fuse, it's just that we need to be able to transform the function if needed before fusing it. That is, something like:

f.(g.(x...)) --> broadcast(_ -> maybe_lift(f, maybe_lift(g, _...)), x...)

with

maybe_lift(f, _...) = f(_...)

for non-lifted functions (the current case).

Of course we can always define special methods instead of using the dot syntax. But I prefer trying to push the logic as far as possible to see all implications and (in)consistency of the design choice first.

@stevengj
Copy link
Member

But if you do e.g. sqrt.(abs.(x)) on a nullable array, wouldn't you want to lift only once, not once for abs and once for sqrt?

@nalimilan
Copy link
Member Author

Ideally, yes. If we inline the lifting logic, the compiler could be able to do the checks only once (haven't checked). If not, a smarter design would be:

f.(g.(x...)) --> broadcast(_ -> maybe_lift((f, g), x...))

maybe_lift would lift only once for all subsequent calls that need it.

@stevengj
Copy link
Member

stevengj commented Nov 14, 2016

@nalimilan, that sounds like it could be implemented without any parser changes at all. Couldn't you just define something like

Base.broadcast(f, ...arguments including a NullableArray...) =
    invoke(broadcast, (...NullableArray replaced by AbstractArray...), (args...) -> lift(f, args..), ...arguments...)

@nalimilan
Copy link
Member Author

That's what I'm doing currently. It works when all functions lift, but it fails when fusing a non-lifting function like isnull with another one. I really need access to the original function before fusing to know what to do.

@JeffBezanson
Copy link
Member

Trying to automatically tell whether a function should be lifted or whether it expects to see Nullables is never going to work. It's not even a well-formed question; for example is tuple a lifting function? You can make tuples of anything, so there's no way to know.

@nalimilan
Copy link
Member Author

@JeffBezanson There's nothing automatic in the mechanism I described. Only functions for which lift has been overridden would use a behaviour different from the default (which is lifting). Is that an issue?

@davidagold
Copy link
Contributor

davidagold commented Nov 15, 2016

To be clear, the list of such functions is quite short: &, | (for three-valued logic), isnull, get. Perhaps unsafe_get belongs in there, too. It's possible that this list expands, but if it does, it hopefully does so very, very slowly.

EDIT: Technically, & and | are not "non-lifting" -- they still need to be lifted for three-valued logic semantics, but we define specialized lift(::typeof(&), x, y) methods.

@nalimilan
Copy link
Member Author

I've made some investigations regarding a concrete implementation. It seems that we could replace the current function fusing code in the parser with a call to broadcast_fuse(types::Type, f, fs...) which (by default) returns an anonymous function composing f and fs.... The types argument would be used to pass the types of the inputs to broadcast, which would allow providing a custom method for NullableArray.

Performance-wise, this gives the same results as the current approach AFAICT:
https://gist.github.com/nalimilan/e97e6561c7006b4ce2dba0f85519a2a8

@stevengj Do you think it would be possible/not too hard to change the parser code to do that? This could potentially allow simplifying that code too. (Unfortunately, my Scheme skills are close to null so I'm having a hard time trying to assess this...)

@martinholters
Copy link
Member

IIUC, the fusing is a bit more complicated, as it needs to account for functions with more than one argument. Given only f and g in broadcast_fuse, how would you discern f.(g.(a,b,c)), f.(g.(a,b),c), f.(a, g.(b,c)), f.(g.(a),b,c), ...?

@nalimilan
Copy link
Member Author

Good catch. So I need to find a more complex approach.

@johnmyleswhite
Copy link
Member

johnmyleswhite commented Dec 12, 2016

I think part of this conversation is complicated by the use of the term "non-lifting" (which I would tend to also call lifting) and distinctions between parse-time and later periods when indirection might change behaviors.

As I understand Milan's goal, what he really wants is the following:

  • Broadcast is syntactically replaced in two parts rather than the current one part:
    • First, replace every single call site of f with lift(f)
    • Then do everything else we're currently doing
  • Then we need to assume the following:
    • lift(f) generates a new callable that, given non-nullable typed arguments, behaves identically to f.
    • If there are any nullable typed-arguments, lift(f) will, by default, extend the behavior of f from its non-nullable typed definition so that it generates normal outputs if all arguments are non-null values, but generates a null value if any arguments are a null value.
    • If there are any nullable typed-arguments and lift(f) has had custom methods added to it, then those custom methods will allow the behaviors that Milan has been calling "non-lifting".

So basically everything would be lifted, but calling the lifted function with non-nullable arguments is identical to calling the original function. Only in other cases (where arguments are Nullable, NullableArray, etc.) would this additional lift(f) indirection lead to behavior different from working with f all the way through.

@nalimilan
Copy link
Member Author

Indeed, a simpler approach would be to always add lift calls from the parser, since they are no-ops when no argument is a Nullable.

@johnmyleswhite
Copy link
Member

Assuming we don't hit any performance issues dealing with all the inlining that is likely to be needed, would there be objections to such an approach?

@stevengj
Copy link
Member

It seems a little weird to add a parser transformation that seems like it would only be usable by a single package...?

@johnmyleswhite
Copy link
Member

I think this approach is part of our attempts to propose making nullables a much larger part of Julia's design (and to move all of the basic lifting into Base Julia). Partly this came up when discussing C#'s design with Erik Meijer and Eric Lippert, who had originally proposed a design for C# that is very similar to what I think Milan would like to see in Julia.

@martinholters
Copy link
Member

How much of a headache is it to write lift given that any function applications in it would also be rewritten? Would we need special syntax for function application without implicit lift?

@nalimilan
Copy link
Member Author

@martinholters I'm not sure I understand your question. Can you develop?

@martinholters
Copy link
Member

Say, for the default case, lift constructs a Lifted{F<:Function} where (::Lifted)(args...) has the required overloads:

immutable Lifted{F<:Function}
    f::F
end
(l::Lifted)(args...) = l.f(args...)
# and overloads for Nullable arguments...
lift(f) = Lifted(f)

If the parser turns this into

immutable Lifted{F<:Function}
    f::F
end
(l::Lifted)(args...) = lift(l.f)(args...)
# and overloads for Nullable arguments...
lift(f) = lift(Lifted)(f)

I see infinite recursion looming...

@nalimilan
Copy link
Member Author

No, the parser would only transform broadcast calls (cf. the OP).

@martinholters
Copy link
Member

Ahhh, makes sense, sorry for the noise.

mbauman added a commit that referenced this issue Apr 23, 2018
This patch represents the combined efforts of four individuals, over 60
commits, and an iterated design over (at least) three pull requests that
spanned nearly an entire year (closes #22063, #23692, #25377 by superceding
them).

This introduces a pure Julia data structure that represents a fused broadcast
expression.  For example, the expression `2 .* (x .+ 1)` lowers to:

```julia
julia> Meta.@lower 2 .* (x .+ 1)
:($(Expr(:thunk, CodeInfo(:(begin
      Core.SSAValue(0) = (Base.getproperty)(Base.Broadcast, :materialize)
      Core.SSAValue(1) = (Base.getproperty)(Base.Broadcast, :make)
      Core.SSAValue(2) = (Base.getproperty)(Base.Broadcast, :make)
      Core.SSAValue(3) = (Core.SSAValue(2))(+, x, 1)
      Core.SSAValue(4) = (Core.SSAValue(1))(*, 2, Core.SSAValue(3))
      Core.SSAValue(5) = (Core.SSAValue(0))(Core.SSAValue(4))
      return Core.SSAValue(5)
  end)))))
```

Or, slightly more readably as:

```julia
using .Broadcast: materialize, make
materialize(make(*, 2, make(+, x, 1)))
```

The `Broadcast.make` function serves two purposes. Its primary purpose is to
construct the `Broadcast.Broadcasted` objects that hold onto the function, the
tuple of arguments (potentially including nested `Broadcasted` arguments), and
sometimes a set of `axes` to include knowledge of the outer shape. The
secondary purpose, however, is to allow an "out" for objects that _don't_ want
to participate in fusion. For example, if `x` is a range in the above `2 .* (x
.+ 1)` expression, it needn't allocate an array and operate elementwise — it
can just compute and return a new range. Thus custom structures are able to
specialize `Broadcast.make(f, args...)` just as they'd specialize on `f`
normally to return an immediate result.

`Broadcast.materialize` is identity for everything _except_ `Broadcasted`
objects for which it allocates an appropriate result and computes the
broadcast. It does two things: it `initialize`s the outermost `Broadcasted`
object to compute its axes and then `copy`s it.

Similarly, an in-place fused broadcast like `y .= 2 .* (x .+ 1)` uses the exact
same expression tree to compute the right-hand side of the expression as above,
and then uses `materialize!(y, make(*, 2, make(+, x, 1)))` to `instantiate` the
`Broadcasted` expression tree and then `copyto!` it into the given destination.

All-together, this forms a complete API for custom types to extend and
customize the behavior of broadcast (fixes #22060). It uses the existing
`BroadcastStyle`s throughout to simplify dispatch on many arguments:

* Custom types can opt-out of broadcast fusion by specializing
  `Broadcast.make(f, args...)` or `Broadcast.make(::BroadcastStyle, f, args...)`.

* The `Broadcasted` object computes and stores the type of the combined
  `BroadcastStyle` of its arguments as its first type parameter, allowing for
  easy dispatch and specialization.

* Custom Broadcast storage is still allocated via `broadcast_similar`, however
  instead of passing just a function as a first argument, the entire
  `Broadcasted` object is passed as a final argument. This potentially allows
  for much more runtime specialization dependent upon the exact expression
  given.

* Custom broadcast implmentations for a `CustomStyle` are defined by
  specializing `copy(bc::Broadcasted{CustomStyle})` or
  `copyto!(dest::AbstractArray, bc::Broadcasted{CustomStyle})`.

* Fallback broadcast specializations for a given output object of type `Dest`
  (for the `DefaultArrayStyle` or another such style that hasn't implemented
  assignments into such an object) are defined by specializing
  `copyto(dest::Dest, bc::Broadcasted{Nothing})`.

As it fully supports range broadcasting, this now deprecates `(1:5) + 2` to
`.+`, just as had been done for all `AbstractArray`s in general.

As a first-mover proof of concept, LinearAlgebra uses this new system to
improve broadcasting over structured arrays. Before, broadcasting over a
structured matrix would result in a sparse array. Now, broadcasting over a
structured matrix will _either_ return an appropriately structured matrix _or_
a dense array. This does incur a type instability (in the form of a
discriminated union) in some situations, but thanks to type-based introspection
of the `Broadcasted` wrapper commonly used functions can be special cased to be
type stable.  For example:

```julia
julia> f(d) = round.(Int, d)
f (generic function with 1 method)

julia> @inferred f(Diagonal(rand(3)))
3×3 Diagonal{Int64,Array{Int64,1}}:
 0  ⋅  ⋅
 ⋅  0  ⋅
 ⋅  ⋅  1

julia> @inferred Diagonal(rand(3)) .* 3
ERROR: return type Diagonal{Float64,Array{Float64,1}} does not match inferred return type Union{Array{Float64,2}, Diagonal{Float64,Array{Float64,1}}}
Stacktrace:
 [1] error(::String) at ./error.jl:33
 [2] top-level scope

julia> @inferred Diagonal(1:4) .+ Bidiagonal(rand(4), rand(3), 'U') .* Tridiagonal(1:3, 1:4, 1:3)
4×4 Tridiagonal{Float64,Array{Float64,1}}:
 1.30771  0.838589   ⋅          ⋅
 0.0      3.89109   0.0459757   ⋅
  ⋅       0.0       4.48033    2.51508
  ⋅        ⋅        0.0        6.23739
```

In addition to the issues referenced above, it fixes:

* Fixes #19313, #22053, #23445, and #24586: Literals are no longer treated
  specially in a fused broadcast; they're just arguments in a `Broadcasted`
  object like everything else.

* Fixes #21094: Since broadcasting is now represented by a pure Julia
  datastructure it can be created within `@generated` functions and serialized.

* Fixes #26097: The fallback destination-array specialization method of
  `copyto!` is specifically implemented as `Broadcasted{Nothing}` and will not
  be confused by `nothing` arguments.

* Fixes the broadcast-specific element of #25499: The default base broadcast
  implementation no longer depends upon `Base._return_type` to allocate its
  array (except in the empty or concretely-type cases). Note that the sparse
  implementation (#19595) is still dependent upon inference and is _not_ fixed.

* Fixes #25340: Functions are treated like normal values just like arguments
  and only evaluated once.

* Fixes #22255, and is performant with 12+ fused broadcasts. Okay, that one was
  fixed on master already, but this fixes it now, too.

* Fixes #25521.

* The performance of this patch has been thoroughly tested through its
  iterative development process in #25377. There remain [two classes of
  performance regressions](#25377) that Nanosoldier flagged.

* #25691: Propagation of constant literals sill lose their constant-ness upon
  going through the broadcast machinery. I believe quite a large number of
  functions would need to be marked as `@pure` to support this -- including
  functions that are intended to be specialized.

(For bookkeeping, this is the squashed version of the [teh-jn/lazydotfuse](#25377)
branch as of a1d4e7e. Squashed and separated
out to make it easier to review and commit)

Co-authored-by: Tim Holy <tim.holy@gmail.com>
Co-authored-by: Jameson Nash <vtjnash@gmail.com>
Co-authored-by: Andrew Keller <ajkeller34@users.noreply.github.com>
Keno pushed a commit that referenced this issue Apr 27, 2018
This patch represents the combined efforts of four individuals, over 60
commits, and an iterated design over (at least) three pull requests that
spanned nearly an entire year (closes #22063, #23692, #25377 by superceding
them).

This introduces a pure Julia data structure that represents a fused broadcast
expression.  For example, the expression `2 .* (x .+ 1)` lowers to:

```julia
julia> Meta.@lower 2 .* (x .+ 1)
:($(Expr(:thunk, CodeInfo(:(begin
      Core.SSAValue(0) = (Base.getproperty)(Base.Broadcast, :materialize)
      Core.SSAValue(1) = (Base.getproperty)(Base.Broadcast, :make)
      Core.SSAValue(2) = (Base.getproperty)(Base.Broadcast, :make)
      Core.SSAValue(3) = (Core.SSAValue(2))(+, x, 1)
      Core.SSAValue(4) = (Core.SSAValue(1))(*, 2, Core.SSAValue(3))
      Core.SSAValue(5) = (Core.SSAValue(0))(Core.SSAValue(4))
      return Core.SSAValue(5)
  end)))))
```

Or, slightly more readably as:

```julia
using .Broadcast: materialize, make
materialize(make(*, 2, make(+, x, 1)))
```

The `Broadcast.make` function serves two purposes. Its primary purpose is to
construct the `Broadcast.Broadcasted` objects that hold onto the function, the
tuple of arguments (potentially including nested `Broadcasted` arguments), and
sometimes a set of `axes` to include knowledge of the outer shape. The
secondary purpose, however, is to allow an "out" for objects that _don't_ want
to participate in fusion. For example, if `x` is a range in the above `2 .* (x
.+ 1)` expression, it needn't allocate an array and operate elementwise — it
can just compute and return a new range. Thus custom structures are able to
specialize `Broadcast.make(f, args...)` just as they'd specialize on `f`
normally to return an immediate result.

`Broadcast.materialize` is identity for everything _except_ `Broadcasted`
objects for which it allocates an appropriate result and computes the
broadcast. It does two things: it `initialize`s the outermost `Broadcasted`
object to compute its axes and then `copy`s it.

Similarly, an in-place fused broadcast like `y .= 2 .* (x .+ 1)` uses the exact
same expression tree to compute the right-hand side of the expression as above,
and then uses `materialize!(y, make(*, 2, make(+, x, 1)))` to `instantiate` the
`Broadcasted` expression tree and then `copyto!` it into the given destination.

All-together, this forms a complete API for custom types to extend and
customize the behavior of broadcast (fixes #22060). It uses the existing
`BroadcastStyle`s throughout to simplify dispatch on many arguments:

* Custom types can opt-out of broadcast fusion by specializing
  `Broadcast.make(f, args...)` or `Broadcast.make(::BroadcastStyle, f, args...)`.

* The `Broadcasted` object computes and stores the type of the combined
  `BroadcastStyle` of its arguments as its first type parameter, allowing for
  easy dispatch and specialization.

* Custom Broadcast storage is still allocated via `broadcast_similar`, however
  instead of passing just a function as a first argument, the entire
  `Broadcasted` object is passed as a final argument. This potentially allows
  for much more runtime specialization dependent upon the exact expression
  given.

* Custom broadcast implmentations for a `CustomStyle` are defined by
  specializing `copy(bc::Broadcasted{CustomStyle})` or
  `copyto!(dest::AbstractArray, bc::Broadcasted{CustomStyle})`.

* Fallback broadcast specializations for a given output object of type `Dest`
  (for the `DefaultArrayStyle` or another such style that hasn't implemented
  assignments into such an object) are defined by specializing
  `copyto(dest::Dest, bc::Broadcasted{Nothing})`.

As it fully supports range broadcasting, this now deprecates `(1:5) + 2` to
`.+`, just as had been done for all `AbstractArray`s in general.

As a first-mover proof of concept, LinearAlgebra uses this new system to
improve broadcasting over structured arrays. Before, broadcasting over a
structured matrix would result in a sparse array. Now, broadcasting over a
structured matrix will _either_ return an appropriately structured matrix _or_
a dense array. This does incur a type instability (in the form of a
discriminated union) in some situations, but thanks to type-based introspection
of the `Broadcasted` wrapper commonly used functions can be special cased to be
type stable.  For example:

```julia
julia> f(d) = round.(Int, d)
f (generic function with 1 method)

julia> @inferred f(Diagonal(rand(3)))
3×3 Diagonal{Int64,Array{Int64,1}}:
 0  ⋅  ⋅
 ⋅  0  ⋅
 ⋅  ⋅  1

julia> @inferred Diagonal(rand(3)) .* 3
ERROR: return type Diagonal{Float64,Array{Float64,1}} does not match inferred return type Union{Array{Float64,2}, Diagonal{Float64,Array{Float64,1}}}
Stacktrace:
 [1] error(::String) at ./error.jl:33
 [2] top-level scope

julia> @inferred Diagonal(1:4) .+ Bidiagonal(rand(4), rand(3), 'U') .* Tridiagonal(1:3, 1:4, 1:3)
4×4 Tridiagonal{Float64,Array{Float64,1}}:
 1.30771  0.838589   ⋅          ⋅
 0.0      3.89109   0.0459757   ⋅
  ⋅       0.0       4.48033    2.51508
  ⋅        ⋅        0.0        6.23739
```

In addition to the issues referenced above, it fixes:

* Fixes #19313, #22053, #23445, and #24586: Literals are no longer treated
  specially in a fused broadcast; they're just arguments in a `Broadcasted`
  object like everything else.

* Fixes #21094: Since broadcasting is now represented by a pure Julia
  datastructure it can be created within `@generated` functions and serialized.

* Fixes #26097: The fallback destination-array specialization method of
  `copyto!` is specifically implemented as `Broadcasted{Nothing}` and will not
  be confused by `nothing` arguments.

* Fixes the broadcast-specific element of #25499: The default base broadcast
  implementation no longer depends upon `Base._return_type` to allocate its
  array (except in the empty or concretely-type cases). Note that the sparse
  implementation (#19595) is still dependent upon inference and is _not_ fixed.

* Fixes #25340: Functions are treated like normal values just like arguments
  and only evaluated once.

* Fixes #22255, and is performant with 12+ fused broadcasts. Okay, that one was
  fixed on master already, but this fixes it now, too.

* Fixes #25521.

* The performance of this patch has been thoroughly tested through its
  iterative development process in #25377. There remain [two classes of
  performance regressions](#25377) that Nanosoldier flagged.

* #25691: Propagation of constant literals sill lose their constant-ness upon
  going through the broadcast machinery. I believe quite a large number of
  functions would need to be marked as `@pure` to support this -- including
  functions that are intended to be specialized.

(For bookkeeping, this is the squashed version of the [teh-jn/lazydotfuse](#25377)
branch as of a1d4e7e. Squashed and separated
out to make it easier to review and commit)

Co-authored-by: Tim Holy <tim.holy@gmail.com>
Co-authored-by: Jameson Nash <vtjnash@gmail.com>
Co-authored-by: Andrew Keller <ajkeller34@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
broadcast Applying a function over a collection won't change Indicates that work won't continue on an issue or pull request
Projects
None yet
7 participants