API — observing and analysing

What a run went through (Observing), and what a caller could reach (Analysing).

ExperimentalAPI.Entry — Type
Entry

One marked definition that the current run entered, as reported by entered.

count is always nothing. The default layer knows whether a definition was entered, never how often — a per-call counter costs 3.76x on eight threads and loses 40% of its increments to races unless it is atomic. Counting is record's job, and it is opt-in for that reason.

source
ExperimentalAPI.Probe — Type
Probe

The one-field flag @experimental puts in a marked body, and the counter record reads.

Not part of the public surface — it is named rather than gensym'd only so that MyModule.__EXPERIMENTAL_API_ENTERED_energy__ is greppable when a query result surprises someone.

entered is the whole default layer: read on every call, written on the first. The remaining fields are untouched unless a recording is open.

source
ExperimentalAPI.detecting — Method
detecting() -> Bool

Whether the summary at process exit is armed.

On by default, because a user who never asks is exactly the one who needs to be told. Set ENV["EXPERIMENTALAPI_SUMMARY"] = "0" before using ExperimentalAPI to silence it; the atexit hook is registered at load time, so a later assignment has no effect.

Detection itself is not a switch: the flag is in the definition and costs nothing to leave on. See overhead_when_detecting for the figure that justifies that sentence.

source
ExperimentalAPI.entered — Method
entered(m::Module) -> Vector{Entry}
entered() -> Vector{Entry}

The marked definitions this run has entered at least once.

Without an argument, every loaded module that carries marks. This is the question a docstring cannot answer: not "is this name experimental" but "did the number I am about to publish come out of code nobody has validated".

A definition that was never called is absent, not reported with a count of zero.

julia> MyPkg.energy(0.5);

julia> entered()
1-element Vector{ExperimentalAPI.Entry}:
 Entry(MyPkg.energy, "convergence not established below β ≈ 0.1")

Only definitions with a body are observed; see @experimental for which forms those are.

source
ExperimentalAPI.marked_modules — Method
marked_modules() -> Vector{Module}

Every loaded module that carries at least one @experimental mark.

Found by walking the loaded packages rather than by a registry inside this package: a table here would be written while the marked package is precompiled, and so would be absent from its cache image. Same constraint that puts the marks themselves in the marked module.

source
ExperimentalAPI.overhead_when_detecting — Method
overhead_when_detecting() -> Float64

The measured cost of the default layer, as a fraction of the unmarked body's run time.

0.03. Not computed at call time: it is the figure from the measurement that chose this mechanism — 10M calls of a realistic numeric body on Julia 1.12.2, minimum of 7–9 trials, giving 1.03x on one thread and 0.985x on eight. The eight-thread figure is below one because a flag written once and read thereafter stops dirtying its cache line.

It is reported rather than re-measured because a wall-clock measurement on a shared CI runner is a flake generator, and because a number that moves with the machine cannot be the thing a caller plans against. record reports its own overhead per run, which is the opposite case: that one depends on how often the marked code was entered.

source
ExperimentalAPI.probes — Method
probes(m::Module) -> Vector{Probe}
probes() -> Vector{Probe}

Every Probe a module carries — one per marked definition that has a body.

The recording layer arms and reads these; nothing else should need them. Exposed because record's cost model is only checkable by someone who can see how many probes there are.

source
ExperimentalAPI.summary_text — Function
summary_text() -> String
summary_text(es::AbstractVector{Entry}) -> String

What the exit summary prints, as a string. Empty when nothing marked was entered.

Carries the reason, not just the name: the name tells a reader which line to look at, the reason tells them whether the result they are holding is affected.

source
ExperimentalAPI.Attribution — Type
Attribution

One marked definition's share of a profile buffer, as reported by attribute.

samples is a sample count, never a call count: inclusive is the fraction of samples with this definition anywhere on the stack, exclusive the fraction with it on top.

source
ExperimentalAPI.Hit — Type
Hit

One marked definition a record block entered, and what it cost.

field
mod, namewhich marked definition
reasoncarried into the record, so a reader a year later needs no source
counthow many times it was entered, summed over threads
methodthe method the mark attached to, when it attached to one
callersthe distinct immediate callers seen
pathsthe distinct call paths seen, innermost first, bounded
inclusive, exclusiveseconds, or missing when no timing backend was loaded

count is exact. paths is a bounded sample: a backtrace costs microseconds, so the recorder stops looking once it has seen enough, and a path that only occurs after the budget is spent is absent. inclusive counts time in anything this definition called; exclusive counts only time in the definition itself, which is what separates a marked wrapper over settled code from a marked kernel.

source
ExperimentalAPI.Record — Type
Record

What record observed: a Vector-like of Hit, plus what the run was.

property
enabledwhether recording was actually on — an empty record means "nothing was entered", and that is a different statement from "nothing was recorded"
slotsthread slots the counters were sized for, at least Threads.maxthreadid()
elapsedseconds the recorded call took
overheadthe recorder's estimated share of elapsed
versionspackage versions the marks were read against
sampledwhether a timing backend produced inclusive/exclusive
valuewhat f returned, so measuring a call does not mean losing its result

Indexing, iteration and == are the Hit vector's, so record(f) == [] reads the way it looks. The extra properties are why it is a type and not a plain vector: an empty Vector{Hit} cannot tell "touched nothing" from "recording was off", and those mean opposite things.

source
ExperimentalAPI.TimingBackend — Type
TimingBackend

How record gets inclusive/exclusive time. The one implementation lives in this package's Profile extension; without it, timing is missing rather than zero.

source
ExperimentalAPI.assert_clean — Method
assert_clean(f; throw = true) -> Bool

Run f and assert it entered nothing marked.

The gate. true when the run touched no marked definition; otherwise it throws, naming every mark it went through and why — or returns false if throw = false, which is what a caller doing its own reporting wants.

assert_clean() do
    publish(compute(model))
end
source
ExperimentalAPI.attribute — Method
attribute(data) -> Vector{Attribution}

Attribute an existing profile buffer to marked definitions, after the fact.

using Profile
Profile.@profile long_run()
attribute(Profile.fetch())

The twelve-hour-run case: a job that was already profiled must not have to be run again to learn which of its time went through unvalidated code. What comes back is samples, not calls — which is why it is an Attribution and not a Hit. A sampling profiler cannot count entries, and a field called count holding a sample total would read as a measurement it did not make.

What sampling cannot see, and why counts exist

A marked definition small enough to be inlined into its caller may leave no separately attributable samples at all: after inlining there is no frame to attribute them to, and the time is charged to whatever the optimiser left at that address. That is not a defect here, it is what a sampling profiler is. It is also the reason record's count is exact and comes from a counter rather than from samples.

source
ExperimentalAPI.experimental_fraction — Method
experimental_fraction(r::Record) -> Union{Float64,Missing}

The share of the recorded run spent inside marked code, in [0, 1].

missing when no TimingBackend was loaded — a run whose time was never attributed has no fraction, and reporting 0.0 would say the opposite of what is known. 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.

source
ExperimentalAPI.merge_records — Method
merge_records(rs) -> Record

Combine records — from separate processes, separate workers, or separate blocks — into one.

Counts add, paths and callers union, elapsed times add. Order-independent and associative: workers finish in whatever order they finish in, and provenance must not depend on that.

source
ExperimentalAPI.read_record — Method
read_record(path::AbstractString) -> Record

Read back a record written by write_record.

The method field of every Hit comes back nothing, and so does the record's value: neither a Method nor a run's result is a thing a file can carry, and reconstructing one would mean claiming the code in this process is the code that produced the record.

`mod` falls back to `Main` when the module is not loaded here

The file carries a module's name and Hit.mod is a Module. Absent from this process — the ordinary case when shards are merged elsewhere — mod is Main, and the report reads Main.energy for a name that is not in Main.

source
ExperimentalAPI.record — Method
record(f; paths = true, timing = false, with_profile = false, rethrow = true) -> Record

Run f and report which marked definitions it entered, how often, and by which paths.

r = record() do
    simulate(model; steps = 10_000)
end
isempty(r) || @warn "used unvalidated code" [(h.name, h.count) for h in r]

A definition that was never entered is absent, not reported with a count of zero: the record says what happened, and enumerating what did not is experimental's job.

keyword
pathscapture call paths. Bounded — see Hit — and on by default
timingask the TimingBackend for inclusive/exclusive. Off by default, and cannot be combined with paths
with_profileleave whatever is already in the profile buffer alone instead of clearing it
rethrowfalse returns the record for the part of f that ran instead of propagating

Nests: an inner record reports its own block, and the outer one still counts each entry once. Counts are exact under threads — per-thread counters sized by Threads.maxthreadid(), not nthreads(), because the interactive pool means a task can have a thread id above the worker count.

This costs something, which is why it is a call

Every marked body takes its write path while a recording is open. The record reports the recorder's estimated share of the elapsed time in overhead; overhead_when_detecting is the other number, and it is 3%.

Paths and time are two instruments, and they cannot be read at once

Capturing a call path calls backtrace(); the timing backend's sampler walks the same threads' stacks from outside, and two unwinders on one stack is a segmentation fault rather than a wrong number. Measured on 1.12.7 with 150 threaded records per run, four runs of each combination: paths alone 0/4 crashed, timing alone 0/4, both 2/4. Asking for both is refused rather than risked, and the default is paths — the instrument that needs no sampler and has no global side effect.

source
ExperimentalAPI.recording — Method
recording() -> Bool

Whether a record block is open.

false by default and outside record, which is the whole cost argument: detection is on always and free, counting happens only where somebody asked for it.

source
ExperimentalAPI.write_record — Method
write_record(path::AbstractString, r::Record) -> String

Write a record to TOML. Returns path.

Evidence, not a printout: what a run went through belongs next to the result it produced, and it has to be readable by something that is not this package — a year later the package may not resolve. See stamp for the same idea aimed at a result file rather than at a record.

source
ExperimentalAPI.@entered — Macro
@entered expr
@entered io expr

Evaluate expr, print which marked definitions it went through, and return its value.

The question entered answers about a whole process, asked about one call:

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.correlator  ×  500 — edge cases at zero separation untested
│   MyPkg.energy      ×10000 — convergence not established below β ≈ 0.1
└ 15 of 17 observable marked definitions were not entered
0.42713…

Sorted by name, not by count — correlator before energy — because a report whose order moves with the measurement cannot be diffed between two runs.

The value of expr comes back, so this drops into existing code the way @time does. The last line is the one that makes a clean answer mean something:

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 report that could not tell them apart would be worth nothing on a package that has no marks yet.

What it is, exactly

record(() -> expr; paths = false, timing = false), plus the report, returning the record's value. It asks which and how often — the cheap question, and the one that needs neither a backtrace nor a sampler. Call record directly for call paths, for inclusive/exclusive time (never both — see the measurement in its docstring), and for the Record as data. This returns the value of expr, not the record.

Why the route is not printed

A call path is captured as a list of frame names, and Base's higher-order functions are in it. Measured on 1.12.2 for driver(x, n) = sum(inner(x) for _ in 1:n), the captured path is

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, including keyword-dispatch wrappers and a generator closure. Printing that would be worse than printing nothing, and separating the two needs paths to carry which module each frame came from — a change to what Hit.paths means, not a change to this macro.

If `expr` throws

The exception propagates and nothing is printed. record(f; rethrow = false) is the form that hands back what a failed run went through, which is usually the run you want it for.

`return` inside `expr` returns from `expr`, not from your function

This is the one place the @time comparison breaks, and it breaks because @time splices the expression where you wrote it while this has to run it inside a closure — record takes a function. So return exits the expression and becomes the macro's value:

f(x) = (ExperimentalAPI.@entered (x > 5 && return :early); :normal)
f(10)   # :normal — `@time` in the same place would give :early

return @entered … is unaffected, because there the expression's value is what the function returns. An assignment has the same shape: @entered y = f(x) binds y inside the closure, so at global scope no y appears afterwards. Write y = @entered f(x) instead — which is what the value coming back is for.

The report goes to stdout unless an io is named first — @entered log sweep(model) puts it in a file, @entered devnull f(x) throws it away and leaves only the value. The default is looked up when the block runs rather than when the macro expands, so redirect_stdout still catches it.

See also entered for the whole-process question, record for the full instrument, and reach for the same question asked without running anything.

source
ExperimentalAPI.Reach — Type
Reach

What reach found.

field
entrywhat was analysed
reachedthe marked definitions proved reachable
unresolvedthe call sites that could not be pinned to a method
through_modulesevery module the walk went through
affected_entriesfor a module or script entry: the public entry points that are not clean
visitedhow many distinct signatures were inferred
truncatedwhether a bound stopped the walk — maxdepth or maxwork

truncated is not cosmetic: when it is true, reached is a lower bound. Measured on a module with twelve marked definitions behind one loop, budgets between the two extremes report :depends with one, two, … of them found and the rest never walked to. :depends is still the right verdict, but "fix the one it named" is not the same as "fix everything it depends on".

There is deliberately no verdict field. A stored verdict makes :clean with a non-empty unresolved representable, and that state is the single thing this analysis must never report. verdict derives it instead, the way isbreaking derives its answer from a Diff.

source
ExperimentalAPI.Reached — Type
Reached

One marked definition the analysis proved reachable from the entry point.

method is the method that was resolved — nothing when the dependency is not a call, which is what a marked const is. path is the chain of names from the entry point down to it, so the reader learns which of their own code to distrust rather than only that something is wrong.

source
ExperimentalAPI.Unresolved — Type
Unresolved

One call site the analysis could not pin to a method.

field
calleethe name being called, as far as the IR knows it
signaturewhat it was called with — the widened argument types
why:dynamic, :ambiguous, :maxdepth, :budget, :splat or :nomethod
file, linewhere to go and look
withinthe method the call site is in
candidatesthe marked methods this site could reach, if any are visible

"Cannot tell" is only actionable if the reader can go and look, which is what file/line are for. candidates is the difference between "some call here is dynamic" and "this call could go to k(::KB), which is marked".

source
ExperimentalAPI.combine — Method
combine(a::Symbol, b::Symbol) -> Symbol

Fold two verdicts into one: :clean < :unknown < :depends.

Needed because reach on a module folds one answer per public entry point into one answer for the module. Commutative and associative, so the order the entry points come back in cannot change the result.

source
ExperimentalAPI.dependents — Method
dependents(m::Module, name::Symbol; kwargs...) -> Vector{Symbol}

The public names of m whose call graph reaches name.

Propagation read backwards, which is the direction the question is actually asked in: a mark gets deleted because somebody looked at the definition, not at who reaches it. Compare reach(m; ignore = [name]) to see what removing it would change.

source
ExperimentalAPI.isclean — Method
isclean(r::Reach) -> Bool

Whether verdict is :clean. false for :unknown as well as for :depends — the predicate answers "may I rely on this", and the honest non-answer is not a yes.

source
ExperimentalAPI.reach — Method
reach(f, types::Type{<:Tuple}; maxdepth = 32, maxwork = 20_000, ignore = Symbol[]) -> Reach
reach(m::Module; kwargs...) -> Reach

Report whether calling f with types can reach anything declared @experimental — including through callers that never name it.

r = reach(analyse, Tuple{Model,Float64})
verdict(r) === :clean || error("depends on: ", [x.mark.name for x in r.reached])

The module form folds every public entry point of m into one answer and reports which of them are affected in affected_entries: function-by-function does not scale to a package.

ignore names marks to treat as absent, which answers "what would removing this mark change?" without removing it.

Three bounds keep the walk finite, and hitting any of them is reported as :unknown, never as :clean. maxdepth bounds how far it goes, maxcandidates bounds how many methods a single ambiguous call site is willing to check, and maxwork bounds the total — because depth alone does not: 32 levels branching by 16 is not a finite amount of work, and the signatures a higher-order call generates need not repeat. Measured on 1.14.0-DEV, [f(x) for x in xs] did not return without maxwork, while the same call answers in milliseconds on 1.12. Raise it for a large entry point that comes back :unknown with a :budget in unresolved.

What it can and cannot resolve

call site
a named call, however deepresolved
a function passed as a valueresolved — Julia specialises on typeof(f)
@nospecialized callee, called with a concrete functionresolved
invoke(f, Tuple{Integer}, x)resolved to the method invoke pins, not the one dispatch would pick
a Union- or abstract-typed argument with methods on both sides:unknown — no unique method
a callee read out of a field or a table:unknown
a marked const or struct used in the body:depends

A more specific unmarked method shadowing a marked one is resolved as what actually runs: Int goes to more_specific(::Int) and is clean, while UInt8 falls through to the marked ::Integer and is not.

It answers about code, not about a run

This is static: it says what could be reached. entered and record say what was. A path this reports is not necessarily taken, and a :clean here is only as good as the call graph being closed — which is what :unknown exists to admit.

source
ExperimentalAPI.reach_script — Method
reach_script(path::AbstractString; kwargs...) -> Reach

The same analysis for a script — a file that produces a figure rather than a package.

Top-level declarations that cannot live inside a function (using, import, module, const, type definitions) are evaluated in a scratch module, because the analysis has to resolve the names the script uses; everything else is analysed as one thunk. So this loads the script's dependencies, and a script whose top level has side effects will have them.

source
ExperimentalAPI.verdict — Method
verdict(r::Reach) -> Symbol

:depends, :unknown or :clean, derived from what reach found.

  • :depends — a marked definition is reachable. Proved, not suspected.
  • :clean — the whole call graph was resolved and nothing marked is in it.
  • :unknown — at least one call site could not be pinned to a method, and nothing marked was proved reachable through the rest.

:depends wins over :unknown: an unresolvable call elsewhere does not make a proved dependency less proved. :unknown wins over :clean, which is the whole point.

source