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> ExperimentalAPI.entered()
1-element Vector{ExperimentalAPI.Entry}:
ExperimentalAPI.Entry(Main.energy, "convergence not established below β ≈ 0.1")The display above is a transcript rather than a doctest on purpose: Entry prints its module, and Documenter evaluates doctests in a sandbox whose module does not print as Main, so a doctest here would show a line no reader ever sees at their own REPL. The fields it is read for do not depend on where it ran, so those are checked:
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() do
simulate(model; steps = 10_000)
endjulia> r
Record — 1 marked definition entered in 0.42s
MyModel.energy ×10000 — convergence not established below β ≈ 0.1
inclusive 0.31s exclusive 0.28s
via record → sweep → step → energy
recorder overhead ≈ 4.1%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:
julia> ExperimentalAPI.@entered sweep(model; βs = 0.05:0.05:2.0)
┌ @entered sweep(model; βs = 0.05:0.05:2.0) at sweep.jl:42
│ MyPkg.energy ×10000 — convergence not established below β ≈ 0.1
│ MyPkg.correlator × 500 — edge cases at zero separation untested
└ 15 of 17 observable marked definitions were not entered
0.42713…It returns the value of the expression, so it drops into existing code the way @time does. The last line is what makes a clean answer mean anything:
julia> ExperimentalAPI.@entered publish(result)
┌ @entered publish(result) at sweep.jl:57
└ entered nothing marked — 17 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.
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.
The route is deliberately not printed: a captured path is a list of frame names, and Base's higher-order functions are in it. sum(f, xs) over a generator reports driver → sum → mapreduce → mapfoldl → mapfoldl_impl → foldl_impl → _foldl_impl → MappingRF → inner → energy — three names the reader wrote and seven 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.