Skip to content

Commit 184ad5b

Browse files
nickrobinson251vilterpkpamnanyNHDalyvchuravy
authored
Add per-task metrics (#56320)
Close #47351 (builds on top of #48416) Adds two per-task metrics: - running time = amount of time the task was actually running (according to our scheduler). Note: currently inclusive of GC time, but would be good to be able to separate that out (in a future PR) - wall time = amount of time between the scheduler becoming aware of this task and the task entering a terminal state (i.e. done or failed). We record running time in `wait()`, where the scheduler stops running the task as well as in `yield(t)`, `yieldto(t)` and `throwto(t)`, which bypass the scheduler. Other places where a task stops running (for `Channel`, `ReentrantLock`, `Event`, `Timer` and `Semaphore` are all implemented in terms of `wait(Condition)`, which in turn calls `wait()`. `LibuvStream` similarly calls `wait()`. This should capture everything (albeit, slightly over-counting task CPU time by including any enqueuing work done before we hit `wait()`). The various metrics counters could be a separate inlined struct if we think that's a useful abstraction, but for now i've just put them directly in `jl_task_t`. They are all atomic, except the `metrics_enabled` flag itself (which we now have to check on task start/switch/done even if metrics are not enabled) which is set on task construction and marked `const` on the julia side. In future PRs we could add more per-task metrics, e.g. compilation time, GC time, allocations, potentially a wait-time breakdown (time waiting on locks, channels, in the scheduler run queue, etc.), potentially the number of yields. Perhaps in future there could be ways to enable this on a per-thread and per-task basis. And potentially in future these same timings could be used by `@time` (e.g. writing this same timing data to a ScopedValue like in #55103 but only for tasks lexically scoped to inside the `@time` block). Timings are off by default but can be turned on globally via starting Julia with `--task-metrics=yes` or calling `Base.Experimental.task_metrics(true)`. Metrics are collected for all tasks created when metrics are enabled. In other words, enabling/disabling timings via `Base.Experimental.task_metrics` does not affect existing `Task`s, only new `Task`s. The other new APIs are `Base.Experimental.task_running_time_ns(::Task)` and `Base.Experimental.task_wall_time_ns(::Task)` for retrieving the new metrics. These are safe to call on any task (including the current task, or a task running on another thread). All these are in `Base.Experimental` to give us room to change up the APIs as we add more metrics in future PRs (without worrying about release timelines). cc @NHDaly @kpamnany @d-netto --------- Co-authored-by: Pete Vilter <[email protected]> Co-authored-by: K Pamnany <[email protected]> Co-authored-by: Nathan Daly <[email protected]> Co-authored-by: Valentin Churavy <[email protected]>
1 parent fe26173 commit 184ad5b

20 files changed

+529
-26
lines changed

NEWS.md

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ New language features
1717
- atomic set once (`@atomiconce v[3] = 2`),
1818
- atomic swap (`x = @atomicswap v[3] = 2`), and
1919
- atomic replace (`x = @atomicreplace v[3] 2=>5`).
20+
- New option `--task-metrics=yes` to enable the collection of per-task timing information,
21+
which can also be enabled/disabled at runtime with `Base.Experimental.task_metrics(::Bool)`. ([#56320])
22+
The available metrics are:
23+
- actual running time for the task (`Base.Experimental.task_running_time_ns`), and
24+
- wall-time for the task (`Base.Experimental.task_wall_time_ns`).
2025

2126
Language changes
2227
----------------

base/boot.jl

+22-4
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,33 @@
175175
#end
176176

177177
#mutable struct Task
178-
# parent::Task
178+
# next::Any
179+
# queue::Any
179180
# storage::Any
180-
# state::Symbol
181181
# donenotify::Any
182182
# result::Any
183-
# exception::Any
184-
# backtrace::Any
185183
# scope::Any
186184
# code::Any
185+
# @atomic _state::UInt8
186+
# sticky::UInt8
187+
# priority::UInt16
188+
# @atomic _isexception::UInt8
189+
# pad00::UInt8
190+
# pad01::UInt8
191+
# pad02::UInt8
192+
# rngState0::UInt64
193+
# rngState1::UInt64
194+
# rngState2::UInt64
195+
# rngState3::UInt64
196+
# rngState4::UInt64
197+
# const metrics_enabled::Bool
198+
# pad10::UInt8
199+
# pad11::UInt8
200+
# pad12::UInt8
201+
# @atomic first_enqueued_at::UInt64
202+
# @atomic last_started_running_at::UInt64
203+
# @atomic running_time_ns::UInt64
204+
# @atomic finished_at::UInt64
187205
#end
188206

189207
export

base/experimental.jl

+74
Original file line numberDiff line numberDiff line change
@@ -503,4 +503,78 @@ usage, by eliminating the tracking of those possible invalidation.
503503
"""
504504
disable_new_worlds() = ccall(:jl_disable_new_worlds, Cvoid, ())
505505

506+
### Task metrics
507+
508+
"""
509+
Base.Experimental.task_metrics(::Bool)
510+
511+
Enable or disable the collection of per-task metrics.
512+
A `Task` created when `Base.Experimental.task_metrics(true)` is in effect will have
513+
[`Base.Experimental.task_running_time_ns`](@ref) and [`Base.Experimental.task_wall_time_ns`](@ref)
514+
timing information available.
515+
516+
!!! note
517+
Task metrics can be enabled at start-up via the `--task-metrics=yes` command line option.
518+
"""
519+
function task_metrics(b::Bool)
520+
if b
521+
ccall(:jl_task_metrics_enable, Cvoid, ())
522+
else
523+
ccall(:jl_task_metrics_disable, Cvoid, ())
524+
end
525+
return nothing
506526
end
527+
528+
"""
529+
Base.Experimental.task_running_time_ns(t::Task) -> Union{UInt64, Nothing}
530+
531+
Return the total nanoseconds that the task `t` has spent running.
532+
This metric is only updated when `t` yields or completes unless `t` is the current task, in
533+
which it will be updated continuously.
534+
See also [`Base.Experimental.task_wall_time_ns`](@ref).
535+
536+
Returns `nothing` if task timings are not enabled.
537+
See [`Base.Experimental.task_metrics`](@ref).
538+
539+
!!! note "This metric is from the Julia scheduler"
540+
A task may be running on an OS thread that is descheduled by the OS
541+
scheduler, this time still counts towards the metric.
542+
543+
!!! compat "Julia 1.12"
544+
This method was added in Julia 1.12.
545+
"""
546+
function task_running_time_ns(t::Task=current_task())
547+
t.metrics_enabled || return nothing
548+
if t == current_task()
549+
# These metrics fields can't update while we're running.
550+
# But since we're running we need to include the time since we last started running!
551+
return t.running_time_ns + (time_ns() - t.last_started_running_at)
552+
else
553+
return t.running_time_ns
554+
end
555+
end
556+
557+
"""
558+
Base.Experimental.task_wall_time_ns(t::Task) -> Union{UInt64, Nothing}
559+
560+
Return the total nanoseconds that the task `t` was runnable.
561+
This is the time since the task first entered the run queue until the time at which it
562+
completed, or until the current time if the task has not yet completed.
563+
See also [`Base.Experimental.task_running_time_ns`](@ref).
564+
565+
Returns `nothing` if task timings are not enabled.
566+
See [`Base.Experimental.task_metrics`](@ref).
567+
568+
!!! compat "Julia 1.12"
569+
This method was added in Julia 1.12.
570+
"""
571+
function task_wall_time_ns(t::Task=current_task())
572+
t.metrics_enabled || return nothing
573+
start_at = t.first_enqueued_at
574+
start_at == 0 && return UInt64(0)
575+
end_at = t.finished_at
576+
end_at == 0 && return time_ns() - start_at
577+
return end_at - start_at
578+
end
579+
580+
end # module

base/options.jl

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ struct JLOptions
6161
heap_size_hint::UInt64
6262
trace_compile_timing::Int8
6363
trim::Int8
64+
task_metrics::Int8
6465
end
6566

6667
# This runs early in the sysimage != is not defined yet

base/task.jl

+49-4
Original file line numberDiff line numberDiff line change
@@ -977,7 +977,11 @@ function enq_work(t::Task)
977977
return t
978978
end
979979

980-
schedule(t::Task) = enq_work(t)
980+
function schedule(t::Task)
981+
# [task] created -scheduled-> wait_time
982+
maybe_record_enqueued!(t)
983+
enq_work(t)
984+
end
981985

982986
"""
983987
schedule(t::Task, [val]; error=false)
@@ -1031,6 +1035,8 @@ function schedule(t::Task, @nospecialize(arg); error=false)
10311035
t.queue === nothing || Base.error("schedule: Task not runnable")
10321036
setfield!(t, :result, arg)
10331037
end
1038+
# [task] created -scheduled-> wait_time
1039+
maybe_record_enqueued!(t)
10341040
enq_work(t)
10351041
return t
10361042
end
@@ -1064,11 +1070,15 @@ immediately yields to `t` before calling the scheduler.
10641070
Throws a `ConcurrencyViolationError` if `t` is the currently running task.
10651071
"""
10661072
function yield(t::Task, @nospecialize(x=nothing))
1067-
current = current_task()
1068-
t === current && throw(ConcurrencyViolationError("Cannot yield to currently running task!"))
1073+
ct = current_task()
1074+
t === ct && throw(ConcurrencyViolationError("Cannot yield to currently running task!"))
10691075
(t._state === task_state_runnable && t.queue === nothing) || throw(ConcurrencyViolationError("yield: Task not runnable"))
1076+
# [task] user_time -yield-> wait_time
1077+
record_running_time!(ct)
1078+
# [task] created -scheduled-> wait_time
1079+
maybe_record_enqueued!(t)
10701080
t.result = x
1071-
enq_work(current)
1081+
enq_work(ct)
10721082
set_next_task(t)
10731083
return try_yieldto(ensure_rescheduled)
10741084
end
@@ -1082,13 +1092,18 @@ call to `yieldto`. This is a low-level call that only switches tasks, not consid
10821092
or scheduling in any way. Its use is discouraged.
10831093
"""
10841094
function yieldto(t::Task, @nospecialize(x=nothing))
1095+
ct = current_task()
10851096
# TODO: these are legacy behaviors; these should perhaps be a scheduler
10861097
# state error instead.
10871098
if t._state === task_state_done
10881099
return x
10891100
elseif t._state === task_state_failed
10901101
throw(t.result)
10911102
end
1103+
# [task] user_time -yield-> wait_time
1104+
record_running_time!(ct)
1105+
# [task] created -scheduled-unfairly-> wait_time
1106+
maybe_record_enqueued!(t)
10921107
t.result = x
10931108
set_next_task(t)
10941109
return try_yieldto(identity)
@@ -1102,6 +1117,10 @@ function try_yieldto(undo)
11021117
rethrow()
11031118
end
11041119
ct = current_task()
1120+
# [task] wait_time -(re)started-> user_time
1121+
if ct.metrics_enabled
1122+
@atomic :monotonic ct.last_started_running_at = time_ns()
1123+
end
11051124
if ct._isexception
11061125
exc = ct.result
11071126
ct.result = nothing
@@ -1115,6 +1134,11 @@ end
11151134

11161135
# yield to a task, throwing an exception in it
11171136
function throwto(t::Task, @nospecialize exc)
1137+
ct = current_task()
1138+
# [task] user_time -yield-> wait_time
1139+
record_running_time!(ct)
1140+
# [task] created -scheduled-unfairly-> wait_time
1141+
maybe_record_enqueued!(t)
11181142
t.result = exc
11191143
t._isexception = true
11201144
set_next_task(t)
@@ -1167,6 +1191,9 @@ checktaskempty = Partr.multiq_check_empty
11671191
end
11681192

11691193
function wait()
1194+
ct = current_task()
1195+
# [task] user_time -yield-or-done-> wait_time
1196+
record_running_time!(ct)
11701197
GC.safepoint()
11711198
W = workqueue_for(Threads.threadid())
11721199
poptask(W)
@@ -1181,3 +1208,21 @@ if Sys.iswindows()
11811208
else
11821209
pause() = ccall(:pause, Cvoid, ())
11831210
end
1211+
1212+
# update the `running_time_ns` field of `t` to include the time since it last started running.
1213+
function record_running_time!(t::Task)
1214+
if t.metrics_enabled && !istaskdone(t)
1215+
@atomic :monotonic t.running_time_ns += time_ns() - t.last_started_running_at
1216+
end
1217+
return t
1218+
end
1219+
1220+
# if this is the first time `t` has been added to the run queue
1221+
# (or the first time it has been unfairly yielded to without being added to the run queue)
1222+
# then set the `first_enqueued_at` field to the current time.
1223+
function maybe_record_enqueued!(t::Task)
1224+
if t.metrics_enabled && t.first_enqueued_at == 0
1225+
@atomic :monotonic t.first_enqueued_at = time_ns()
1226+
end
1227+
return t
1228+
end

doc/man/julia.1

+4
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ If --trace-compile is enabled show how long each took to compile in ms
294294
--trace-dispatch={stderr|name}
295295
Print precompile statements for methods dispatched during execution or save to stderr or a path.
296296

297+
.TP
298+
--task-metrics={yes|no*}
299+
Enable the collection of per-task metrics.
300+
297301
.TP
298302
-image-codegen
299303
Force generate code in imaging mode

doc/src/base/multi-threading.md

+8
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,11 @@ These building blocks are used to create the regular synchronization objects.
6565
```@docs
6666
Base.Threads.SpinLock
6767
```
68+
69+
## Task metrics (Experimental)
70+
71+
```@docs
72+
Base.Experimental.task_metrics
73+
Base.Experimental.task_running_time_ns
74+
Base.Experimental.task_wall_time_ns
75+
```

doc/src/manual/command-line-interface.md

+1
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ The following is a complete list of command-line switches available when launchi
203203
|`--code-coverage=tracefile.info` |Append coverage information to the LCOV tracefile (filename supports format tokens).|
204204
|`--track-allocation[={none*\|user\|all}]` |Count bytes allocated by each source line (omitting setting is equivalent to "user")|
205205
|`--track-allocation=@<path>` |Count bytes but only in files that fall under the given file path/directory. The `@` prefix is required to select this option. A `@` with no path will track the current directory.|
206+
|`--task-metrics={yes\|no*}` |Enable the collection of per-task metrics|
206207
|`--bug-report=KIND` |Launch a bug report session. It can be used to start a REPL, run a script, or evaluate expressions. It first tries to use BugReporting.jl installed in current environment and falls back to the latest compatible BugReporting.jl if not. For more information, see `--bug-report=help`.|
207208
|`--heap-size-hint=<size>` |Forces garbage collection if memory usage is higher than the given value. The value may be specified as a number of bytes, optionally in units of KB, MB, GB, or TB, or as a percentage of physical memory with %.|
208209
|`--compile={yes*\|no\|all\|min}` |Enable or disable JIT compiler, or request exhaustive or minimal compilation|

src/init.c

+4
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,10 @@ JL_DLLEXPORT void julia_init(JL_IMAGE_SEARCH rel)
849849
#if defined(_COMPILER_GCC_) && __GNUC__ >= 12
850850
#pragma GCC diagnostic ignored "-Wdangling-pointer"
851851
#endif
852+
if (jl_options.task_metrics == JL_OPTIONS_TASK_METRICS_ON) {
853+
// enable before creating the root task so it gets timings too.
854+
jl_atomic_fetch_add(&jl_task_metrics_enabled, 1);
855+
}
852856
// warning: this changes `jl_current_task`, so be careful not to call that from this function
853857
jl_task_t *ct = jl_init_root_task(ptls, stack_lo, stack_hi);
854858
#pragma GCC diagnostic pop

src/jlapi.c

+22
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,28 @@ JL_DLLEXPORT uint64_t jl_cumulative_recompile_time_ns(void)
809809
return jl_atomic_load_relaxed(&jl_cumulative_recompile_time);
810810
}
811811

812+
/**
813+
* @brief Enable per-task timing.
814+
*/
815+
JL_DLLEXPORT void jl_task_metrics_enable(void)
816+
{
817+
// Increment the flag to allow reentrant callers.
818+
jl_atomic_fetch_add(&jl_task_metrics_enabled, 1);
819+
}
820+
821+
/**
822+
* @brief Disable per-task timing.
823+
*/
824+
JL_DLLEXPORT void jl_task_metrics_disable(void)
825+
{
826+
// Prevent decrementing the counter below zero
827+
uint8_t enabled = jl_atomic_load_relaxed(&jl_task_metrics_enabled);
828+
while (enabled > 0) {
829+
if (jl_atomic_cmpswap(&jl_task_metrics_enabled, &enabled, enabled-1))
830+
break;
831+
}
832+
}
833+
812834
/**
813835
* @brief Retrieve floating-point environment constants.
814836
*

src/jloptions.c

+12
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ JL_DLLEXPORT void jl_init_options(void)
152152
0, // heap-size-hint
153153
0, // trace_compile_timing
154154
JL_TRIM_NO, // trim
155+
0, // task_metrics
155156
};
156157
jl_options_initialized = 1;
157158
}
@@ -316,6 +317,7 @@ static const char opts_hidden[] =
316317
" comment if color is not supported\n"
317318
" --trace-compile-timing If --trace-compile is enabled show how long each took to\n"
318319
" compile in ms\n"
320+
" --task-metrics={yes|no*} Enable collection of per-task timing data.\n"
319321
" --image-codegen Force generate code in imaging mode\n"
320322
" --permalloc-pkgimg={yes|no*} Copy the data section of package images into memory\n"
321323
" --trim={no*|safe|unsafe|unsafe-warn}\n"
@@ -347,6 +349,7 @@ JL_DLLEXPORT void jl_parse_opts(int *argcp, char ***argvp)
347349
opt_trace_compile,
348350
opt_trace_compile_timing,
349351
opt_trace_dispatch,
352+
opt_task_metrics,
350353
opt_math_mode,
351354
opt_worker,
352355
opt_bind_to,
@@ -427,6 +430,7 @@ JL_DLLEXPORT void jl_parse_opts(int *argcp, char ***argvp)
427430
{ "trace-compile", required_argument, 0, opt_trace_compile },
428431
{ "trace-compile-timing", no_argument, 0, opt_trace_compile_timing },
429432
{ "trace-dispatch", required_argument, 0, opt_trace_dispatch },
433+
{ "task-metrics", required_argument, 0, opt_task_metrics },
430434
{ "math-mode", required_argument, 0, opt_math_mode },
431435
{ "handle-signals", required_argument, 0, opt_handle_signals },
432436
// hidden command line options
@@ -978,6 +982,14 @@ JL_DLLEXPORT void jl_parse_opts(int *argcp, char ***argvp)
978982
else
979983
jl_errorf("julia: invalid argument to --trim={safe|no|unsafe|unsafe-warn} (%s)", optarg);
980984
break;
985+
case opt_task_metrics:
986+
if (!strcmp(optarg, "no"))
987+
jl_options.task_metrics = JL_OPTIONS_TASK_METRICS_OFF;
988+
else if (!strcmp(optarg, "yes"))
989+
jl_options.task_metrics = JL_OPTIONS_TASK_METRICS_ON;
990+
else
991+
jl_errorf("julia: invalid argument to --task-metrics={yes|no} (%s)", optarg);
992+
break;
981993
default:
982994
jl_errorf("julia: unhandled option -- %c\n"
983995
"This is a bug, please report it.", c);

src/jloptions.h

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ typedef struct {
6565
uint64_t heap_size_hint;
6666
int8_t trace_compile_timing;
6767
int8_t trim;
68+
int8_t task_metrics;
6869
} jl_options_t;
6970

7071
#endif

0 commit comments

Comments
 (0)