Skip to content

Commit 1c9ecc2

Browse files
add a vibe coded Test.detect_closure_boxes for closure boxes in loaded code (#60478)
Co-authored-by: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com>
1 parent 909400d commit 1c9ecc2

File tree

5 files changed

+124
-4
lines changed

5 files changed

+124
-4
lines changed

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ Standard library changes
7373
* `@test_throws`, `@test_warn`, `@test_nowarn`, `@test_logs`, and `@test_deprecated` now support
7474
`broken` and `skip` keyword arguments for consistency with `@test` ([#60543]).
7575

76+
* New functions `detect_closure_boxes` and `detect_closure_boxes_all` find methods that
77+
allocate `Core.Box` in their lowered code, which can indicate performance issues from
78+
captured variables in closures.
79+
7680
#### InteractiveUtils
7781

7882
#### Dates

stdlib/Test/docs/src/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,8 @@ Test.GenericOrder
420420
Test.GenericSet
421421
Test.GenericString
422422
Test.detect_ambiguities
423+
Test.detect_closure_boxes
424+
Test.detect_closure_boxes_all_modules
423425
Test.detect_unbound_args
424426
```
425427

stdlib/Test/src/Test.jl

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export @test, @test_throws, @test_broken, @test_skip,
2626

2727
export @testset
2828
export @inferred
29-
export detect_ambiguities, detect_unbound_args
29+
export detect_ambiguities, detect_unbound_args, detect_closure_boxes, detect_closure_boxes_all_modules
3030
export GenericString, GenericSet, GenericDict, GenericArray, GenericOrder
3131
export TestSetException
3232
export TestLogger, LogRecord
@@ -2615,7 +2615,7 @@ function detect_ambiguities(mods::Module...;
26152615
allowed_undefineds = nothing)
26162616
@nospecialize
26172617
ambs = Set{Tuple{Method,Method}}()
2618-
mods = collect(mods)::Vector{Module}
2618+
mods = Module[mods...]
26192619
function sortdefs(m1::Method, m2::Method)
26202620
ord12 = cmp(m1.file, m2.file)
26212621
if ord12 == 0
@@ -2645,6 +2645,86 @@ function detect_ambiguities(mods::Module...;
26452645
return collect(ambs)
26462646
end
26472647

2648+
"""
2649+
detect_closure_boxes(mod1, mod2...)
2650+
2651+
Return a sorted `Vector{Pair{Method, Vector{Symbol}}}` of methods defined in the
2652+
specified modules (or their submodules) that allocate `Core.Box` in their lowered
2653+
code, paired with the boxed variable names. Variable names are `:unknown` when a
2654+
slot name cannot be resolved.
2655+
2656+
See also [`detect_closure_boxes_all_modules`](@ref) to check all loaded modules.
2657+
"""
2658+
function detect_closure_boxes(mods::Module...)
2659+
@nospecialize
2660+
boxes = Dict{Method, Vector{Symbol}}()
2661+
mods = Module[mods...]
2662+
isempty(mods) && return Pair{Method, Vector{Symbol}}[]
2663+
2664+
function is_box_call(@nospecialize expr)
2665+
if !(expr isa Expr)
2666+
return false
2667+
end
2668+
if expr.head === :call || expr.head === :new
2669+
callee = expr.args[1]
2670+
return callee === Core.Box || (callee isa GlobalRef && callee.mod === Core && callee.name === :Box)
2671+
end
2672+
return false
2673+
end
2674+
2675+
function slot_name(ci, slot)::Symbol
2676+
if slot isa Core.SlotNumber
2677+
idx = Int(slot.id)
2678+
if 1 <= idx <= length(ci.slotnames)
2679+
return ci.slotnames[idx]
2680+
end
2681+
end
2682+
return Symbol(string(slot))
2683+
end
2684+
2685+
function matches_module(mod::Module)
2686+
return is_in_mods(mod, true, mods)
2687+
end
2688+
2689+
function scan_method!(m::Method)
2690+
matches_module(parentmodule(m)) || return
2691+
ci = try
2692+
Base.uncompressed_ast(m)
2693+
catch
2694+
return
2695+
end
2696+
for stmt in ci.code
2697+
if stmt isa Expr && stmt.head === :(=)
2698+
lhs = stmt.args[1]
2699+
rhs = stmt.args[2]
2700+
if is_box_call(rhs)
2701+
push!(get!(Vector{Symbol}, boxes, m), slot_name(ci, lhs))
2702+
end
2703+
elseif is_box_call(stmt)
2704+
push!(get!(Vector{Symbol}, boxes, m), :unknown)
2705+
end
2706+
end
2707+
end
2708+
2709+
Base.visit(Core.methodtable) do m
2710+
scan_method!(m)
2711+
end
2712+
2713+
result = collect(boxes)
2714+
sort!(result, by = entry -> (entry.first.file, entry.first.line, entry.first.name))
2715+
return result
2716+
end
2717+
2718+
"""
2719+
()
2720+
2721+
Return a sorted `Vector{Pair{Method, Vector{Symbol}}}` of all methods in currently
2722+
loaded modules that allocate `Core.Box` in their lowered code.
2723+
2724+
See also [`detect_closure_boxes`](@ref) to check specific modules.
2725+
"""
2726+
detect_closure_boxes_all_modules() = detect_closure_boxes(Base.loaded_modules_array()...)
2727+
26482728
"""
26492729
detect_unbound_args(mod1, mod2...; recursive=false, allowed_undefineds=nothing)
26502730
@@ -2671,7 +2751,7 @@ function detect_unbound_args(mods...;
26712751
allowed_undefineds=nothing)
26722752
@nospecialize mods
26732753
ambs = Set{Method}()
2674-
mods = collect(mods)::Vector{Module}
2754+
mods = Module[mods...]
26752755
function examine(mt::Core.MethodTable)
26762756
for m in Base.MethodList(mt)
26772757
is_in_mods(parentmodule(m), recursive, mods) || continue

stdlib/Test/test/runtests.jl

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,40 @@ import Logging: Debug, Info, Warn, with_logger
2929
@test 'a' .. 'a'
3030
@test !('a' .. 'b')
3131
end
32+
33+
34+
module ClosureBoxTest
35+
function boxed()
36+
x = 1
37+
inner() = (x += 1)
38+
inner()
39+
end
40+
41+
module Sub
42+
function boxed_sub()
43+
x = 0
44+
inner() = (x += 1)
45+
inner()
46+
end
47+
end
48+
end
49+
50+
@testset "detect_closure_boxes" begin
51+
boxes = Test.detect_closure_boxes(ClosureBoxTest)
52+
@test any(p -> p.first.name === :boxed, boxes)
53+
@test any(p -> p.first.name === :boxed_sub, boxes)
54+
55+
sub_boxes = Test.detect_closure_boxes(ClosureBoxTest.Sub)
56+
@test any(p -> p.first.name === :boxed_sub, sub_boxes)
57+
@test all(p -> parentmodule(p.first) === ClosureBoxTest.Sub, sub_boxes)
58+
59+
@test isempty(Test.detect_closure_boxes())
60+
61+
# _all version checks all loaded modules
62+
all_boxes = Test.detect_closure_boxes_all_modules()
63+
@test any(p -> p.first.name === :boxed, all_boxes)
64+
end
65+
3266
@testset "@test with skip/broken kwargs" begin
3367
# Make sure the local variables can be used in conditions
3468
a = 1

test/reflection.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ let
197197
Symbol("@test_logs"), Symbol("@test_nowarn"), Symbol("@test_skip"), Symbol("@test_throws"),
198198
Symbol("@test_warn"), Symbol("@testset"), :GenericArray, :GenericDict, :GenericOrder,
199199
:GenericSet, :GenericString, :LogRecord, :Test, :TestLogger, :TestSetException,
200-
:detect_ambiguities, :detect_unbound_args])
200+
:detect_ambiguities, :detect_closure_boxes, :detect_closure_boxes_all_modules, :detect_unbound_args])
201201
usings_from_Base = delete!(Set(names(Module(); usings=true)), :anonymous) # the name of the anonymous module itself
202202
usings = Set(Symbol[:x36529, :TestModSub9475, :f54609]) usings_from_Test usings_from_Base
203203
@test Set(names(TestMod7648)) == defaultset

0 commit comments

Comments
 (0)