Skip to content

Commit f659f18

Browse files
authored
Add an opt-in profiler for model macros (#3981)
1 parent e501331 commit f659f18

File tree

10 files changed

+238
-11
lines changed

10 files changed

+238
-11
lines changed

docs/src/tutorials/getting_started/debugging.jl

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,39 @@ end
386386
# you do not have enough memory to store the model. Use a computer with more
387387
# RAM.
388388

389-
# ### Strategies
389+
# ### Macro timing
390+
391+
# JuMP has a built-in feature that can measure the time spent in each macro.
392+
# Turn it on using [`set_macro_timing`](@ref), build the model, and then use
393+
# [`print_macro_timing_summary`](@ref) to print a summary. Here's an example:
394+
395+
begin
396+
N = 200
397+
demand = vcat(-1.0, zeros(N - 2), 1.0)
398+
edges = [(i, j) for i in 1:N for j in 1:N if i < j]
399+
model = Model()
400+
set_macro_timing(model, true)
401+
@variable(model, flows[e in edges] >= 0)
402+
@constraint(
403+
model,
404+
[n in 1:N],
405+
sum(flows[(i, j)] for (i, j) in edges if j == n) -
406+
sum(flows[(i, j)] for (i, j) in edges if i == n) == demand[n]
407+
)
408+
print_macro_timing_summary(model)
409+
end
410+
411+
# In this case, you can see that the `@constraint` call dominates the runtime.
412+
# If it isn't obvious why that is, read the [Performance problems with sum-if formulations](@ref)
413+
# tutorial.
414+
415+
# Note that the macro timing feature measures only the time spent inside JuMP
416+
# macros. It does not measure regular Julia code outside the macros.
417+
418+
# ### Other strategies
419+
420+
# If the macro timing feature does not reveal the bottleneck, it means that your
421+
# issue is in regular Julia code that is not inside a JuMP macro.
390422

391423
# The strategy to debug JuMP models that have performance problems depends on
392424
# how long your model takes to build.

src/Containers/macro.jl

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,16 +324,25 @@ end
324324

325325
_strip_LineNumberNode(x) = x
326326

327+
"""
328+
build_macro_expression_string(macro_name, args)
329+
330+
Reconstruct a string of the macro input expression from the name and arguments.
331+
"""
332+
function build_macro_expression_string(macro_name, args)
333+
str_args = join(_strip_LineNumberNode.(args), ", ")
334+
return string("`@", macro_name, "(", str_args, ")`")
335+
end
336+
327337
"""
328338
build_error_fn(macro_name, args, source)
329339
330340
Return a function that can be used in place of `Base.error`, but which
331341
additionally prints the macro from which it was called.
332342
"""
333343
function build_error_fn(macro_name, args, source)
334-
str_args = join(_strip_LineNumberNode.(args), ", ")
335-
msg = "At $(source.file):$(source.line): `@$macro_name($str_args)`: "
336-
error_fn(str...) = error(msg, str...)
344+
msg = build_macro_expression_string(macro_name, args)
345+
error_fn(str...) = error("At $(source.file):$(source.line): $msg: ", str...)
337346
return error_fn
338347
end
339348

src/JuMP.jl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ mutable struct GenericModel{T<:Real} <: AbstractModel
126126
set_string_names_on_creation::Bool
127127
#
128128
variable_in_set_ref::Dict{Any,MOI.ConstraintIndex}
129+
# A dictionary to store timing information from the JuMP macros.
130+
enable_macro_timing::Bool
131+
macro_times::Dict{Tuple{LineNumberNode,String},Float64}
129132
end
130133

131134
value_type(::Type{GenericModel{T}}) where {T} = T
@@ -237,6 +240,8 @@ function direct_generic_model(
237240
Dict{Symbol,Any}(),
238241
true,
239242
Dict{Any,MOI.ConstraintIndex}(),
243+
false,
244+
Dict{Tuple{LineNumberNode,String},Float64}(),
240245
)
241246
end
242247

@@ -937,6 +942,7 @@ function Base.empty!(model::GenericModel)::GenericModel
937942
model.nlp_model = nothing
938943
empty!(model.obj_dict)
939944
empty!(model.ext)
945+
empty!(model.macro_times)
940946
model.is_model_dirty = false
941947
return model
942948
end

src/macros.jl

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,26 +392,52 @@ function _finalize_macro(
392392
source::LineNumberNode;
393393
register_name::Union{Nothing,Symbol} = nothing,
394394
wrap_let::Bool = false,
395+
time_it::Union{Nothing,String} = nothing,
395396
)
396397
@assert Meta.isexpr(model, :escape)
397-
if wrap_let && model.args[1] isa Symbol
398-
code = quote
399-
let $model = $model
398+
ret = gensym()
399+
code = if wrap_let && model.args[1] isa Symbol
400+
quote
401+
$ret = let $model = $model
400402
$code
401403
end
402404
end
405+
else
406+
:($ret = $code)
403407
end
404408
if register_name !== nothing
405409
sym_name = Meta.quot(register_name)
406410
code = quote
407411
_error_if_cannot_register($model, $sym_name)
408-
$(esc(register_name)) = $model[$sym_name] = $code
412+
$code
413+
$(esc(register_name)) = $model[$sym_name] = $ret
414+
end
415+
end
416+
if time_it !== nothing
417+
code = quote
418+
start_time = time()
419+
$code
420+
_add_or_set_macro_time(
421+
$model,
422+
($(QuoteNode(source)), $time_it),
423+
time() - start_time,
424+
)
425+
$ret
409426
end
410427
end
411428
is_valid_code = :(_valid_model($model, $(Meta.quot(model.args[1]))))
412429
return Expr(:block, source, is_valid_code, code)
413430
end
414431

432+
_add_or_set_macro_time(model::AbstractModel, key, value) = nothing
433+
434+
function _add_or_set_macro_time(model::GenericModel, key, value)
435+
if model.enable_macro_timing
436+
model.macro_times[key] = get!(model.macro_times, key, 0.0) + value
437+
end
438+
return
439+
end
440+
415441
function _error_if_cannot_register(model::AbstractModel, name::Symbol)
416442
obj_dict = object_dictionary(model)
417443
if haskey(obj_dict, name)
@@ -474,6 +500,100 @@ function _plural_macro_code(model, block, macro_sym)
474500
return code
475501
end
476502

503+
"""
504+
set_macro_timing(::GenericModel, value::Bool)
505+
506+
Turn on (if `value`, or off, if `!value`) JuMP's built-in profiling of model
507+
construction macros.
508+
509+
Use [`print_macro_timing_summary`](@ref) to display a summary.
510+
511+
## Example
512+
513+
```jldoctest; filter=[r"Total time inside macros: .+ seconds", r"├.+", r"└.+"]
514+
julia> begin
515+
model = Model()
516+
set_macro_timing(model, true)
517+
@variable(model, x[1:2])
518+
@objective(model, Min, sum(x))
519+
end;
520+
521+
julia> print_macro_timing_summary(model)
522+
Total time inside macros: 5.33690e-02 seconds
523+
524+
├ 2.96490e-02 s [55.55%]
525+
│ ├ REPL[8]:3
526+
│ └ `@variable(model, x[1:2])`
527+
528+
└ 2.37200e-02 s [44.45%]
529+
├ REPL[8]:4
530+
└ `@objective(model, Min, sum(x))`
531+
```
532+
"""
533+
function set_macro_timing(model::GenericModel, value::Bool)
534+
model.enable_macro_timing = value
535+
return
536+
end
537+
538+
function _string_summary(x)
539+
if length(x) <= 75
540+
return x
541+
end
542+
return x[1:32] * " [...] " * x[end-31:end]
543+
end
544+
545+
_format_time(x::Float64) = string(_format(x), " seconds")
546+
547+
"""
548+
print_macro_timing_summary([io::IO = stdout], model::GenericModel)
549+
550+
Print a summary of the runtime of each macro.
551+
552+
Before calling this method, you must have enabled the macro timing feature using
553+
[`set_macro_timing`](@ref).
554+
555+
## Example
556+
557+
```jldoctest; filter=[r"Total time inside macros: .+ seconds", r"├.+", r"└.+"]
558+
julia> begin
559+
model = Model()
560+
set_macro_timing(model, true)
561+
@variable(model, x[1:2])
562+
@objective(model, Min, sum(x))
563+
end;
564+
565+
julia> print_macro_timing_summary(model)
566+
Total time inside macros: 5.33690e-02 seconds
567+
568+
├ 2.96490e-02 s [55.55%]
569+
│ ├ REPL[8]:3
570+
│ └ `@variable(model, x[1:2])`
571+
572+
└ 2.37200e-02 s [44.45%]
573+
├ REPL[8]:4
574+
└ `@objective(model, Min, sum(x))`
575+
```
576+
"""
577+
function print_macro_timing_summary(io::IO, model::GenericModel)
578+
total_time = sum(values(model.macro_times))
579+
times = sort!(collect(model.macro_times); by = last, rev = true)
580+
println(io, "Total time inside macros: ", _format_time(total_time))
581+
for i in 1:length(times)
582+
(source, expr), time = times[i]
583+
percent = round(100 * time / total_time; digits = 2)
584+
a, b = ifelse(i < length(times), ('', ''), ('', ' '))
585+
println(io, "")
586+
println(io, "$a ", _format(time), " s [$percent%]")
587+
println(io, "$b$(source.file):$(source.line)")
588+
println(io, "$b", replace(_string_summary(expr), "\n" => ""))
589+
end
590+
return
591+
end
592+
593+
function print_macro_timing_summary(model::GenericModel)
594+
return print_macro_timing_summary(stdout, model)
595+
end
596+
477597
for file in readdir(joinpath(@__DIR__, "macros"))
478598
# The check for .jl is necessary because some users may have other files
479599
# like .cov from running code coverage. See JuMP.jl#3746.

src/macros/@constraint.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ macro constraint(input_args...)
179179
__source__;
180180
register_name = name,
181181
wrap_let = true,
182+
time_it = Containers.build_macro_expression_string(
183+
:constraint,
184+
input_args,
185+
),
182186
)
183187
end
184188

src/macros/@expression.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ macro expression(input_args...)
9494
__source__;
9595
register_name = name,
9696
wrap_let = true,
97+
time_it = Containers.build_macro_expression_string(
98+
:expression,
99+
input_args,
100+
),
97101
)
98102
end
99103

src/macros/@force_nonlinear.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ julia> @expression(model, @force_nonlinear(x * 2.0 * (1 + x) * x))
8484
x * 2.0 * (1 + x) * x
8585
8686
julia> @allocated @expression(model, x * 2.0 * (1 + x) * x)
87-
3584
87+
3680
8888
8989
julia> @allocated @expression(model, @force_nonlinear(x * 2.0 * (1 + x) * x))
90-
672
90+
768
9191
```
9292
"""
9393
macro force_nonlinear(expr)

src/macros/@objective.jl

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,15 @@ macro objective(input_args...)
107107
set_objective($esc_model, $sense, $expr)
108108
$expr
109109
end
110-
return _finalize_macro(esc_model, code, __source__)
110+
return _finalize_macro(
111+
esc_model,
112+
code,
113+
__source__;
114+
time_it = Containers.build_macro_expression_string(
115+
:objective,
116+
input_args,
117+
),
118+
)
111119
end
112120

113121
function _parse_moi_sense(error_fn::Function, sense)

src/macros/@variable.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ macro variable(input_args...)
320320
__source__;
321321
register_name = name,
322322
wrap_let = true,
323+
time_it = Containers.build_macro_expression_string(
324+
:variable,
325+
input_args,
326+
),
323327
)
324328
end
325329

test/test_macros.jl

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2527,4 +2527,44 @@ function test_malformed_kwarg()
25272527
return
25282528
end
25292529

2530+
function test_print_macro_timing_summary()
2531+
model = Model()
2532+
@variable(model, x[1:2]) # This one should be excluded
2533+
set_macro_timing(model, true)
2534+
@objective(model, Min, sum(x))
2535+
@expression(model, x)
2536+
@constraint(model, [i in 1:2], x[i] <= i)
2537+
for i in 1:100
2538+
@constraint(model, sin(x[1]) <= i)
2539+
end
2540+
@variables(model, begin
2541+
a[1:2]
2542+
b, Bin, (start = 0)
2543+
end)
2544+
@expression(
2545+
model,
2546+
a_really_really_really_really_really_really_really_long_name,
2547+
x,
2548+
)
2549+
set_macro_timing(model, false)
2550+
@expression(model, expr, x[1] + x[2]) # So should this one
2551+
print_macro_timing_summary(model)
2552+
contents = sprint(print_macro_timing_summary, model)
2553+
needles = [
2554+
"@variable(model, a[1:2])",
2555+
"@variable(model, b, Bin, start = 0)",
2556+
"@objective(model, Min, sum(x))",
2557+
"@expression(model, x)",
2558+
"@constraint(model, [i in 1:2], x[i] <= i)",
2559+
"@constraint(model, sin(x[1]) <= i)",
2560+
"@expression(model, a_really_rea [...] lly_really_really_long_name, x)",
2561+
]
2562+
@test all(n -> occursin(n, contents), needles)
2563+
@test length(model.macro_times) == length(needles)
2564+
@test all(>=(0), values(model.macro_times))
2565+
@test !occursin("@variable(model, x[1:2])", contents)
2566+
@test !occursin("@expression(model, expr, x[1] + x[2])", contents)
2567+
return
2568+
end
2569+
25302570
end # module

0 commit comments

Comments
 (0)