Skip to content

Commit 77d6e95

Browse files
aviatesktimholy
andauthored
proper termination, take 2 (#117)
This PR is an alternative to #99. This is built on top of #116. With this PR, the following test cases now pass correctly: ```julia #Final block is not a `return`: Need to use #`config::SelectiveEvalRecurse` explicitly ex = quote x = 1 yy = 7 @Label loop x += 1 x < 5 || return yy @goto loop end frame = Frame(ModSelective, ex) src = frame.framecode.src edges = CodeEdges(ModSelective, src) config = SelectiveEvalRecurse() isrequired = lines_required(GlobalRef(ModSelective, :x), src, edges, config) selective_eval_fromstart!(config, frame, isrequired, true) @test ModSelective.x == 5 @test !isdefined(ModSelective, :yy) ``` The basic approach is overloading `JuliaInterpreter.step_expr!` and `LoweredCodeUtils.next_or_nothing!` for the new `SelectiveEvalController` type, as described below, to perform correct selective execution. When `SelectiveEvalController` is passed as the `recurse` argument of `selective_eval!`, the selective execution is adjusted as follows: - **Implicit return**: In Julia's IR representation (`CodeInfo`), the final block does not necessarily return and may `goto` another block. And if the `return` statement is not included in the slice in such cases, it is necessary to terminate `selective_eval!` when execution reaches such implicit return statements. `controller.implicit_returns` records the PCs of such return statements, and `selective_eval!` will return when reaching those statements. This is the core part of the fix for the test cases in #99. - **CFG short-cut**: When the successors of a conditional branch are inactive, and it is safe to move the program counter from the conditional branch to the nearest common post-dominator of those successors, this short-cut is taken. This short-cut is not merely an optimization but is actually essential for the correctness of the selective execution. This is because, in `CodeInfo`, even if we simply fall-through dead blocks (i.e., increment the program counter without executing the statements of those blocks), it does not necessarily lead to the nearest common post-dominator block. And now [`lines_required`](@ref) or [`lines_required!`](@ref) will update the `SelectiveEvalController` passed as their argument to be appropriate for the program slice generated. One thing to note is that currently, the `controller` is not be recursed. That said, in Revise, which is the main consumer of LCU, there is no need for recursive selective execution, and so `selective_eval!` does not provide a system for inter-procedural selective evaluation. Accordingly `SelectiveEvalController` does not recurse too, but this can be left as a future extension. Co-authored-by: Tim Holy <tim.holy@gmail.com>
1 parent e9a98bd commit 77d6e95

6 files changed

Lines changed: 157 additions & 42 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "LoweredCodeUtils"
22
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
3-
version = "3.5.3"
3+
version = "3.6.0"
44
authors = ["Tim Holy <tim.holy@gmail.com>"]
55

66
[deps]

docs/src/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ lines_required
1717
lines_required!
1818
selective_eval!
1919
selective_eval_fromstart!
20+
SelectiveEvalController
2021
```
2122

2223
## Interpreter

src/codeedges.jl

Lines changed: 132 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,60 @@ function postprint_linelinks(io::IO, idx::Int, src::CodeInfo, cl::CodeLinks, bbc
182182
return nothing
183183
end
184184

185+
struct CFGShortCut
186+
from::Int # pc of GotoIfNot with inactive 𝑰𝑵𝑭𝑳 blocks
187+
to::Int # pc of the entry of the nearest common post-dominator of the GotoIfNot's successors
188+
end
189+
190+
"""
191+
controller::SelectiveEvalController
192+
193+
When this object is passed as the `recurse` argument of `selective_eval!`,
194+
the selective execution is adjusted as follows:
195+
196+
- **Implicit return**: In Julia's IR representation (`CodeInfo`), the final block does not
197+
necessarily return and may `goto` another block. In such cases the actual `return` is an
198+
explicit statement earlier in the code, and if the slice does not include it,
199+
`selective_eval!` must still terminate when execution reaches it. `controller.implicit_returns`
200+
records the PCs of such `return` statements, and `selective_eval!` will return when reaching them.
201+
202+
- **CFG short-cut**: When the successors of a conditional branch are inactive, and it is
203+
safe to move the program counter from the conditional branch to the nearest common
204+
post-dominator of those successors, this short-cut is taken.
205+
This short-cut is not merely an optimization but is actually essential for the correctness
206+
of the selective execution. This is because, in `CodeInfo`, even if we simply fall-through
207+
dead blocks (i.e., increment the program counter without executing the statements of those
208+
blocks), it does not necessarily lead to the nearest common post-dominator block.
209+
210+
These adjustments are necessary for performing selective execution correctly.
211+
[`lines_required`](@ref) or [`lines_required!`](@ref) will update the `SelectiveEvalController`
212+
passed as an argument to be appropriate for the program slice generated.
213+
"""
214+
struct SelectiveEvalController
215+
implicit_returns::BitSet # pc where selective execution should terminate even if they're inactive
216+
shortcuts::Vector{CFGShortCut}
217+
end
218+
SelectiveEvalController() = SelectiveEvalController(BitSet(), CFGShortCut[])
219+
220+
"""
221+
struct SelectiveInterpreter{S<:Interpreter,T<:AbstractVector{Bool}} <: Interpreter
222+
inner::S
223+
isrequired::T
224+
controller::SelectiveEvalController
225+
end
226+
227+
An `JuliaInterpreter.Interpreter` that executes only the statements marked `true` in `isrequired`.
228+
Note that this interpreter does not recurse into callee frames.
229+
That is, when `JuliaInterpreter.finish!(interp::SelectiveInterpreter, frame, ...)` is
230+
performed, the `frame` will be executed selectively according to `interp.isrequired`, but
231+
any callee frames within it will be executed by `interp.inner::Interpreter`, not by `interp`.
232+
"""
233+
struct SelectiveInterpreter{S<:Interpreter,T<:AbstractVector{Bool}} <: Interpreter
234+
inner::S
235+
isrequired::T
236+
controller::SelectiveEvalController
237+
end
238+
185239
function namedkeys(cl::CodeLinks)
186240
ukeys = Set{GlobalRef}()
187241
for c in (cl.namepreds, cl.namesuccs, cl.nameassigns)
@@ -606,8 +660,8 @@ function terminal_preds!(s, j, edges, covered) # can't be an inner function bec
606660
end
607661

608662
"""
609-
isrequired = lines_required(obj::GlobalRef, src::CodeInfo, edges::CodeEdges)
610-
isrequired = lines_required(idx::Int, src::CodeInfo, edges::CodeEdges)
663+
isrequired = lines_required(obj::GlobalRef, src::CodeInfo, edges::CodeEdges, [controller::SelectiveEvalController])
664+
isrequired = lines_required(idx::Int, src::CodeInfo, edges::CodeEdges, [controller::SelectiveEvalController])
611665
612666
Determine which lines might need to be executed to evaluate `obj` or the statement indexed by `idx`.
613667
If `isrequired[i]` is `false`, the `i`th statement is *not* required.
@@ -616,21 +670,26 @@ will end up skipping a subset of such statements, perhaps while repeating others
616670
617671
See also [`lines_required!`](@ref) and [`selective_eval!`](@ref).
618672
"""
619-
function lines_required(obj::GlobalRef, src::CodeInfo, edges::CodeEdges; kwargs...)
673+
function lines_required(obj::GlobalRef, src::CodeInfo, edges::CodeEdges,
674+
controller::SelectiveEvalController=SelectiveEvalController();
675+
kwargs...)
620676
isrequired = falses(length(edges.preds))
621677
objs = Set{GlobalRef}([obj])
622-
return lines_required!(isrequired, objs, src, edges; kwargs...)
678+
return lines_required!(isrequired, objs, src, edges, controller; kwargs...)
623679
end
624680

625-
function lines_required(idx::Int, src::CodeInfo, edges::CodeEdges; kwargs...)
681+
function lines_required(idx::Int, src::CodeInfo, edges::CodeEdges,
682+
controller::SelectiveEvalController=SelectiveEvalController();
683+
kwargs...)
626684
isrequired = falses(length(edges.preds))
627685
isrequired[idx] = true
628686
objs = Set{GlobalRef}()
629-
return lines_required!(isrequired, objs, src, edges; kwargs...)
687+
return lines_required!(isrequired, objs, src, edges, controller; kwargs...)
630688
end
631689

632690
"""
633-
lines_required!(isrequired::AbstractVector{Bool}, src::CodeInfo, edges::CodeEdges;
691+
lines_required!(isrequired::AbstractVector{Bool}, src::CodeInfo, edges::CodeEdges,
692+
[controller::SelectiveEvalController];
634693
norequire = ())
635694
636695
Like `lines_required`, but where `isrequired[idx]` has already been set to `true` for all statements
@@ -642,9 +701,11 @@ should _not_ be marked as a requirement.
642701
For example, use `norequire = LoweredCodeUtils.exclude_named_typedefs(src, edges)` if you're
643702
extracting method signatures and not evaluating new definitions.
644703
"""
645-
function lines_required!(isrequired::AbstractVector{Bool}, src::CodeInfo, edges::CodeEdges; kwargs...)
704+
function lines_required!(isrequired::AbstractVector{Bool}, src::CodeInfo, edges::CodeEdges,
705+
controller::SelectiveEvalController=SelectiveEvalController();
706+
kwargs...)
646707
objs = Set{GlobalRef}()
647-
return lines_required!(isrequired, objs, src, edges; kwargs...)
708+
return lines_required!(isrequired, objs, src, edges, controller; kwargs...)
648709
end
649710

650711
function exclude_named_typedefs(src::CodeInfo, edges::CodeEdges)
@@ -664,7 +725,9 @@ function exclude_named_typedefs(src::CodeInfo, edges::CodeEdges)
664725
return norequire
665726
end
666727

667-
function lines_required!(isrequired::AbstractVector{Bool}, objs, src::CodeInfo, edges::CodeEdges; norequire = ())
728+
function lines_required!(isrequired::AbstractVector{Bool}, objs, src::CodeInfo, edges::CodeEdges,
729+
controller::SelectiveEvalController=SelectiveEvalController();
730+
norequire = ())
668731
# Mark any requested objects (their lines of assignment)
669732
objs = add_requests!(isrequired, objs, edges, norequire)
670733

@@ -699,7 +762,10 @@ function lines_required!(isrequired::AbstractVector{Bool}, objs, src::CodeInfo,
699762
end
700763

701764
# now mark the active goto nodes
702-
add_active_gotos!(isrequired, src, cfg, postdomtree)
765+
add_active_gotos!(isrequired, src, cfg, postdomtree, controller)
766+
767+
# check if there are any implicit return blocks
768+
record_implicit_return!(controller, isrequired, cfg)
703769

704770
return isrequired
705771
end
@@ -777,19 +843,19 @@ end
777843

778844
## Add control-flow
779845

780-
781846
# The goal of this function is to request concretization of the minimal necessary control
782847
# flow to evaluate statements whose concretization have already been requested.
783848
# The basic algorithm is based on what was proposed in [^Wei84]. If there is even one active
784849
# block in the blocks reachable from a conditional branch up to its successors' nearest
785850
# common post-dominator (referred to as 𝑰𝑵𝑭𝑳 in the paper), it is necessary to follow
786-
# that conditional branch and execute the code. Otherwise, execution can be short-circuited
851+
# that conditional branch and execute the code. Otherwise, execution can be short-cut
787852
# from the conditional branch to the nearest common post-dominator.
788853
#
789-
# COMBAK: It is important to note that in Julia's intermediate code representation (`CodeInfo`),
790-
# "short-circuiting" a specific code region is not a simple task. Simply ignoring the path
791-
# to the post-dominator does not guarantee fall-through to the post-dominator. Therefore,
792-
# a more careful implementation is required for this aspect.
854+
# It is important to note that in Julia's intermediate code representation (`CodeInfo`),
855+
# "short-cutting" a specific code region is not a simple task. Simply incrementing the
856+
# program counter without executing the statements of 𝑰𝑵𝑭𝑳 blocks does not guarantee that
857+
# the program counter fall-throughs to the post-dominator.
858+
# To handle such cases, `selective_eval!` needs to use `SelectiveInterpreter`.
793859
#
794860
# [Wei84]: M. Weiser, "Program Slicing," IEEE Transactions on Software Engineering, 10, pages 352-357, July 1984.
795861
function add_control_flow!(isrequired, src::CodeInfo, cfg::CFG, postdomtree)
@@ -864,8 +930,8 @@ function reachable_blocks(cfg, from_bb::Int, to_bb::Int)
864930
return visited
865931
end
866932

867-
function add_active_gotos!(isrequired, src::CodeInfo, cfg::CFG, postdomtree)
868-
dead_blocks = compute_dead_blocks(isrequired, src, cfg, postdomtree)
933+
function add_active_gotos!(isrequired, src::CodeInfo, cfg::CFG, postdomtree, controller::SelectiveEvalController)
934+
dead_blocks = compute_dead_blocks!(isrequired, src, cfg, postdomtree, controller)
869935
changed = false
870936
for bbidx = 1:length(cfg.blocks)
871937
if bbidx dead_blocks
@@ -883,7 +949,7 @@ function add_active_gotos!(isrequired, src::CodeInfo, cfg::CFG, postdomtree)
883949
end
884950

885951
# find dead blocks using the same approach as `add_control_flow!`, for the converged `isrequired`
886-
function compute_dead_blocks(isrequired, src::CodeInfo, cfg::CFG, postdomtree)
952+
function compute_dead_blocks!(isrequired, src::CodeInfo, cfg::CFG, postdomtree, controller::SelectiveEvalController)
887953
dead_blocks = BitSet()
888954
for bbidx = 1:length(cfg.blocks)
889955
bb = cfg.blocks[bbidx]
@@ -904,13 +970,31 @@ function compute_dead_blocks(isrequired, src::CodeInfo, cfg::CFG, postdomtree)
904970
end
905971
if !is_𝑰𝑵𝑭𝑳_active
906972
union!(dead_blocks, delete!(𝑰𝑵𝑭𝑳, postdominator))
973+
if postdominator 0
974+
postdominator_bb = cfg.blocks[postdominator]
975+
postdominator_entryidx = postdominator_bb.stmts[begin]
976+
push!(controller.shortcuts, CFGShortCut(termidx, postdominator_entryidx))
977+
end
907978
end
908979
end
909980
end
910981
end
911982
return dead_blocks
912983
end
913984

985+
function record_implicit_return!(controller::SelectiveEvalController, isrequired, cfg::CFG)
986+
for bbidx = 1:length(cfg.blocks)
987+
bb = cfg.blocks[bbidx]
988+
if isempty(bb.succs)
989+
i = findfirst(idx::Int->!isrequired[idx], bb.stmts)
990+
if !isnothing(i)
991+
push!(controller.implicit_returns, bb.stmts[i])
992+
end
993+
end
994+
end
995+
nothing
996+
end
997+
914998
# Do a traveral of "numbered" predecessors and find statement ranges and names of type definitions
915999
function find_typedefs(src::CodeInfo)
9161000
typedef_blocks, typedef_names = UnitRange{Int}[], Symbol[]
@@ -1033,26 +1117,19 @@ function add_inplace!(isrequired, src, edges, norequire)
10331117
return changed
10341118
end
10351119

1036-
"""
1037-
struct SelectiveInterpreter{S<:Interpreter,T<:AbstractVector{Bool}} <: Interpreter
1038-
inner::S
1039-
isrequired::T
1040-
end
1041-
1042-
An `JuliaInterpreter.Interpreter` that executes only the statements marked `true` in `isrequired`.
1043-
Note that this interpreter does not recurse into callee frames.
1044-
That is, when `JuliaInterpreter.finish!(interp::SelectiveInterpreter, frame, ...)` is
1045-
performed, the `frame` will be executed selectively according to `interp.isrequired`, but
1046-
any callee frames within it will be executed by `interp.inner::Interpreter`, not by `interp`.
1047-
"""
1048-
struct SelectiveInterpreter{S<:Interpreter,T<:AbstractVector{Bool}} <: Interpreter
1049-
inner::S
1050-
isrequired::T
1051-
end
10521120
function JuliaInterpreter.step_expr!(interp::SelectiveInterpreter, frame::Frame, istoplevel::Bool)
10531121
pc = frame.pc
1122+
if pc in interp.controller.implicit_returns
1123+
return nothing
1124+
elseif pc_expr(frame) isa GotoIfNot
1125+
for shortcut in interp.controller.shortcuts
1126+
if shortcut.from == pc
1127+
return frame.pc = shortcut.to
1128+
end
1129+
end
1130+
end
10541131
if interp.isrequired[pc]
1055-
step_expr!(interp.inner, frame::Frame, istoplevel::Bool)
1132+
step_expr!(interp.inner, frame, istoplevel)
10561133
else
10571134
next_or_nothing!(interp, frame)
10581135
end
@@ -1083,12 +1160,28 @@ See [`selective_eval_fromstart!`](@ref) to have that performed automatically.
10831160
10841161
`isrequired` pertains only to `frame` itself, not any of its callees.
10851162
1163+
By default `selective_eval!` runs with an empty [`SelectiveEvalController`](@ref), which
1164+
reproduces the historical behavior. That is correct for most top-level code, but not for
1165+
every possible Julia program: when the slice omits a `return` reached by fall-through, or
1166+
omits a branch whose dead region does not simply fall through to its post-dominator, the
1167+
interpreter can execute the wrong statements (see
1168+
https://github.com/JuliaDebug/LoweredCodeUtils.jl/pull/99 for details). For full correctness,
1169+
pass a [`SelectiveEvalController`](@ref) to [`lines_required`](@ref)/[`lines_required!`](@ref)
1170+
and reuse that same controller, together with the `isrequired` it produced, to construct a
1171+
[`SelectiveInterpreter`](@ref) that you run with `JuliaInterpreter.finish_and_return!`.
1172+
Drawing both from the same call is what keeps `isrequired` and the controller synchronized.
1173+
1174+
Note that the interpreter does not recurse into callees, so there is currently no
1175+
interprocedural selective evaluation.
1176+
10861177
This will return either a `BreakpointRef`, the value obtained from the last executed statement
10871178
(if stored to `frame.framedata.ssavlues`), or `nothing`.
10881179
Typically, assignment to a variable binding does not result in an ssa store by JuliaInterpreter.
10891180
"""
1090-
selective_eval!(interp::Interpreter, frame::Frame, isrequired::AbstractVector{Bool}, istoplevel::Bool=false) =
1091-
JuliaInterpreter.finish_and_return!(SelectiveInterpreter(interp, isrequired), frame, istoplevel)
1181+
function selective_eval!(interp::Interpreter, frame::Frame, isrequired::AbstractVector{Bool}, istoplevel::Bool=false)
1182+
interp = SelectiveInterpreter(interp, isrequired, SelectiveEvalController())
1183+
JuliaInterpreter.finish_and_return!(interp, frame, istoplevel)
1184+
end
10921185
selective_eval!(args...) = selective_eval!(RecursiveInterpreter(), args...)
10931186

10941187
"""

src/packagedef.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ const trackedheads = (:method,) # Revise uses this (for now), don't delete; a
2424
const structdecls = (:_structtype, :_abstracttype, :_primitivetype)
2525

2626
export signature, rename_framemethods!, methoddef!, methoddefs!, bodymethod
27-
export CodeEdges, lines_required, lines_required!, selective_eval!, selective_eval_fromstart!
27+
export CodeEdges, SelectiveEvalController, SelectiveInterpreter,
28+
lines_required, lines_required!, selective_eval!, selective_eval_fromstart!
2829

2930
include("utils.jl")
3031
include("signatures.jl")

test/codeedges.jl

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,25 @@ module ModSelective end
223223
@test ModSelective.k11 == 0
224224
@test 3 <= ModSelective.s11 <= 15
225225

226+
# Final block is not a `return`: Need to use `controller::SelectiveEvalController` explicitly
227+
ex = quote
228+
x = 1
229+
yy = 7
230+
@label loop
231+
x += 1
232+
x < 5 || return yy
233+
@goto loop
234+
end
235+
frame = Frame(ModSelective, ex)
236+
src = frame.framecode.src
237+
edges = CodeEdges(ModSelective, src)
238+
controller = SelectiveEvalController()
239+
isrequired = lines_required(GlobalRef(ModSelective, :x), src, edges, controller)
240+
interp = LoweredCodeUtils.SelectiveInterpreter(LoweredCodeUtils.RecursiveInterpreter(), isrequired, controller)
241+
JuliaInterpreter.finish_and_return!(interp, frame, true)
242+
@test ModSelective.x == 5
243+
@test !isdefined(ModSelective, :yy)
244+
226245
# Control-flow in an abstract type definition
227246
ex = :(abstract type StructParent{T, N} <: AbstractArray{T, N} end)
228247
frame = Frame(ModSelective, ex)

test/signatures.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,10 @@ bodymethtest5(x, y=Dict(1=>2)) = 5
436436
oldenv = Pkg.project().path
437437
try
438438
# we test with the old version of CBinding, let's do it in an isolated environment
439+
# so we don't cause package conflicts with everything else
439440
Pkg.activate(; temp=true, io=devnull)
440441

441-
@info "Adding CBinding to the environment for test purposes"
442+
@info "Adding CBinding v0.9.4 to the environment for test purposes"
442443
Pkg.add(; name="CBinding", version="0.9.4", io=devnull) # `@cstruct` isn't defined for v1.0 and above
443444

444445
m = Module()

0 commit comments

Comments
 (0)