API — observing and analysing

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

ExperimentalAPI.EntryType
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.ProbeType
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.detectingMethod
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.enteredMethod
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_modulesMethod
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_detectingMethod
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.probesMethod
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_textFunction
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.AttributionType
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.HitType
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.RecordType
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

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.TimingBackendType
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_cleanMethod
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.attributeMethod
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_fractionMethod
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_recordsMethod
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_recordMethod
read_record(path::AbstractString) -> Record

Read back a record written by write_record.

The method field of every Hit comes back nothing: a Method is not a thing a file can carry, and reconstructing one would mean claiming the code in this process is the code that produced the record.

source
ExperimentalAPI.recordMethod
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.recordingMethod
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_recordMethod
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.@enteredMacro
@entered 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.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…

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. 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: sum(f, xs) over a generator reports driver → sum → mapreduce → mapfoldl → mapfoldl_impl → foldl_impl → _foldl_impl → MappingRF → inner → energy. The three names the reader wrote are in there, and so are seven they did not. 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.

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.ReachType
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 depth limit stopped the walk

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.ReachedType
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.UnresolvedType
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, :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.combineMethod
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.dependentsMethod
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.iscleanMethod
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.reachMethod
reach(f, types::Type{<:Tuple}; maxdepth = 32, 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. maxdepth bounds the walk and maxcandidates bounds how many methods a single ambiguous call site is willing to check; hitting either bound is reported as :unknown, never as :clean.

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_scriptMethod
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.verdictMethod
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