Observing
A docstring can say a name is unfinished. It cannot tell you that this run went through it.
That is the question behind the mark: not "is energy experimental", which the author already knows, but "did the number in this figure come out of code nobody validated" — asked after the run, about the run, by somebody who may not have written either.
Without asking
using ExperimentalAPI
@experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001
energy(0.5)$ julia sweep.jl
┌ ExperimentalAPI: this run entered 1 experimental definition
│ Main.energy — convergence not established below β ≈ 0.1
└ set ENV["EXPERIMENTALAPI_SUMMARY"] = "0" before `using` to silence thisThree properties, each of them a decision:
- On by default. The user who never asks is the one who needs telling.
detectingreports whether the hook is armed; the environment variable has to be set beforeusing ExperimentalAPI, because that is whenatexitis registered. - Silent unless something was entered. Loading a package that has marks prints nothing. A package that cannot be quiet is one people vendor around.
- Carries the reason. The name says which line to open; the reason says whether the result is affected.
As data
entered returns the same thing the summary prints, as a Vector{Entry}:
julia> MyModel.energy(0.5)-1.6265233750364456julia> ExperimentalAPI.entered(MyModel)1-element Vector{ExperimentalAPI.Entry}: Entry(Main.MyModel.energy, "convergence not established below β ≈ 0.1")
Asked about a module rather than about the whole process, because the process-wide answer includes every marked package loaded — on this page, the fixtures the documentation built. The fields it is read for do not depend on where it ran, and those are checked as a doctest:
julia> using ExperimentalAPI
julia> @experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001
julia> energy(0.5);
julia> only(ExperimentalAPI.entered()).name
:energy
julia> only(ExperimentalAPI.entered()).reason
"convergence not established below β ≈ 0.1"
julia> only(ExperimentalAPI.entered()).count === nothing
trueA marked definition the run never entered is absent, not reported with a count of zero — the difference between "observed" and "enumerated".
marked_modules is the search this uses: the loaded modules that carry marks, found by walking rather than by a registry inside this package, because a table here would be written while the marked package is precompiled and so would be missing from its cache image. summary_text is what the exit hook prints, available as a string for a report of your own.
What it costs
One short-circuit read in the body, and a write on the first call only.
| emitted into the body | 1 thread | 8 threads | counts correctly? |
|---|---|---|---|
| nothing | 1.00× | 1.00× | — |
the flag @experimental emits | 1.03× | 0.985× | yes |
a counter, plain shared Ref | 1.03× | 3.76× | no — 40% lost to races |
| a counter, global atomic | 1.17× | 4.87× | yes |
| a counter, per-thread atomic | 1.12× | 2.79× | yes |
@warn, guarded so it fires once | 5.65× | — | yes |
@warn maxlog=1 | 59.57× | — | yes |
10M calls of sqrt(abs(sin(x)cos(x) + exp(-|x|/1e6))), minimum of 7–9 trials, Julia 1.12.2.
Two of those rows decided the design. A flag written once and only read afterwards stops dirtying the cache line, which is why it is free at eight threads while every counting scheme is not. And the guarded @warn costs 5.65× even though it fires once: what stops the definition inlining is the call being in the body at all, not the warning being printed. That is why the notice is a summary at exit rather than a warning at the call.
What the default layer does not answer
- How often. Presence only —
Entry.countis alwaysnothing. - Which method. Flags are name-keyed, so two methods of a marked name share one.
- Which call site, or by what path.
- Anything about a declaration-only mark. A name list, a
struct, aconst, amodule: recorded and audited, never observed. See@experimentalfor the table.
The first three are record's, and it is a call rather than a default because of the table above.
Recording: counts, paths and time
r = ExperimentalAPI.record(; paths = false) do
MyModel.simulate(βs; steps = 500)
endrRecord — 1 marked definition entered in 0.000667s
Main.MyModel.energy ×20000 — convergence not established below β ≈ 0.1
recorder overhead ≈ 11.0%
paths = false here because a captured path is the whole call stack, and on this page that starts at _start and runs through makedocs. The timings are missing: a documentation build does not load Profile, and record reports what it measured rather than a zero.
record returns a Vector-like of Hit, so isempty(r) and r[1].count read the way they look — with the properties an empty vector could not carry:
| property | why it is not just a vector |
|---|---|
enabled | an empty record means "nothing was entered"; without this it is indistinguishable from "nothing was recorded", and those are opposite statements |
slots | thread slots the counters were sized for, at least Threads.maxthreadid() — the interactive pool means a task's thread id can exceed nthreads() |
overhead | the recorder's estimated share of the elapsed time, from a calibrated per-hit cost |
versions | energy being experimental in v0.3 says nothing about v0.9 |
Asking about one call
record(() -> f(x)) is the function form, and it is what everything here is built on. @entered is the same question asked about an expression, and it knows two things a closure cannot — the source text of the call and the line it is written on:
report = IOBuffer()
value = ExperimentalAPI.@entered report MyModel.sweep(βs)
print(String(take!(report)))┌ @entered MyModel.sweep(βs) at observing.md:163
│ Main.MyModel.energy ×40 — convergence not established below β ≈ 0.1
└ 1 of 2 observable marked definitions was not enteredThe footer counts every observable marked definition loaded in the process, not only the ones in your package.
It returns the value of the expression, so it drops into existing code the way @time does — with one divergence @time does not have, since record takes a function and the expression therefore runs inside a closure: a return inside it returns from the closure, and @entered y = f(x) binds y inside the closure. Write y = @entered f(x). The last line is what makes a clean answer mean anything:
ExperimentalAPI.@entered report round(1.0; digits = 2)
print(String(take!(report)))┌ @entered round(1.0; digits = 2) at observing.md:177
└ entered nothing marked — 2 observable marked definitions were loaded"Entered nothing" and "nothing is marked anywhere" are different states, and a package that has not adopted this yet is in the second one. A report that could not tell them apart would read as reassurance on a package where nothing had ever been declared.
What it returns is the record's value — record carries the block's result out, so measuring a call does not cost you that result. It is record(() -> expr; paths = false, timing = false) plus the report: the cheap question, which and how often, needing neither a backtrace nor a sampler. For call paths, time (never both — see record), or the Record as data, call record.
Name an io first to send the report somewhere else — @entered log sweep(model) into a file, @entered devnull f(x) to keep only the value. The default is looked up when the block runs, not when the macro expands, so redirect_stdout still catches it.
The route is deliberately not printed: a captured path is a list of frame names, and Base's higher-order functions are in it. Measured for driver(x, n) = sum(inner(x) for _ in 1:n), the captured path runs driver → sum → #sum#278 → sum → #sum#277 → mapreduce → #mapreduce#274 → mapfoldl → #mapfoldl#270 → mapfoldl_impl → foldl_impl → _foldl_impl → MappingRF → #driver##0 → inner → energy — three names the reader wrote and thirteen they did not. Separating the two needs paths to carry which module each frame came from, which is a change to what Hit.paths means.
How it counts without a counter in the body
The emitted statement never changes. Opening a block clears every probe's flag, so the short-circuit fails and the write side runs on every call — and the write side is a function call, not an inlined store, so it can afford to count. Counts are therefore exact and survive inlining, which is what ruled out the sampling route: a definition small enough to be worth marking is small enough to be inlined, and a sampler has no frame left to attribute to.
Counts are exact under threads too: per-thread counters, sized by maxthreadid() and padded so two threads never share a cache line, summed at the end.
paths is a bounded sample rather than a complete list — a backtrace costs microseconds, so the recorder stops looking once it has seen enough. The paths a marked definition is reached by are few and repeat.
Time
inclusive and exclusive come from Julia's sampling profiler, through a package extension: without using Profile they are missing, which is not zero. A run that nobody timed has no fraction to report, and 0.0 would say the opposite.
experimental_fraction is the share of the run spent inside marked code, derived from the inclusive times — so it is time and not calls. One entry into a marked kernel that runs for a minute matters more than a million into a marked accessor.
attribute does the same for a profile buffer that already exists, which is the twelve-hour-run case: a job that was already profiled must not have to be run again. What comes back is Attribution — samples, never calls, because a sampling profiler cannot count entries and a field called count holding a sample total would read as a measurement it did not make.
As a gate, and as evidence
assert_clean turns a record into a refusal:
ExperimentalAPI.assert_clean() do
publish(compute(model))
endwrite_record and stamp write it down instead. Both produce plain TOML, because a year later the package that made the figure may not resolve — and a provenance record nobody can open is not one.