From ee7baeaf634c37e2343858049acb2bcf5f9d42e3 Mon Sep 17 00:00:00 2001 From: MichaelHatherly Date: Wed, 21 Oct 2026 11:20:11 +0100 Subject: [PATCH 1/2] perf: drop the precompile workload and run the package interpreted The workload from #309 cached about 4,400 specializations, most of them Base methods, and verifying their backedges made DocStringExtensions take 33 ms to load where 0.10.0 took 4 ms. PrecompileTools also pulled in Preferences, TOML and Dates. Each dependent package paid that on every load, while the gain went only to sessions that render a docstring. Without the workload the first render took 1 s. From Julia 1.6 the module now sets `compile=min optimize=0`, keeping inference on so the type stability checks still hold. Interpreted code costs nothing to compile, but a closure passed to a Base function such as `map`, `filter`, `sort!` or a comprehension compiles a new specialization of that function. So the render path uses loops, and `methodgroups` groups by hand and sorts with `insertion_sort!`, replacing `groupby` and two `sort!` calls. The first render now takes about 0.19 s and loading about 4 ms. Closes #313 --- CHANGELOG.md | 4 ++ Project.toml | 4 -- docs/make.jl | 1 - src/DocStringExtensions.jl | 6 +- src/abbreviations/signatures.jl | 18 ++--- src/abbreviations/typedef.jl | 5 +- src/methods.jl | 112 ++++++++++++++++-------------- src/precompile.jl | 66 ------------------ src/printmethod.jl | 108 ++++++++++++++++++++--------- src/type_text.jl | 118 ++++++++++++++++++++++++-------- test/methods.jl | 12 ---- test/printmethod.jl | 16 ++--- 12 files changed, 254 insertions(+), 216 deletions(-) delete mode 100644 src/precompile.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index 6752497f..487201e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Remove the precompilation workload and the PrecompileTools dependency, which made every package depending on DocStringExtensions slower to precompile and load. On Julia 1.6 and later the package runs interpreted, with inference on. Rendering the first docstring of a session takes about 0.2 s, against 1 s in v0.10.0 ([#313](https://github.com/JuliaDocs/DocStringExtensions.jl/issues/313), [#314](https://github.com/JuliaDocs/DocStringExtensions.jl/pull/314)) + ## [v0.10.1](https://github.com/JuliaDocs/DocStringExtensions.jl/releases/tag/v0.10.1) - 2026-10-20 ### Changed diff --git a/Project.toml b/Project.toml index 8e05ccde..0e0df51e 100644 --- a/Project.toml +++ b/Project.toml @@ -2,12 +2,8 @@ name = "DocStringExtensions" uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" version = "0.10.1" -[deps] -PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" - [compat] DeepDiffs = "1" -PrecompileTools = "1" julia = "1" [extras] diff --git a/docs/make.jl b/docs/make.jl index a47b3c12..2a811dfd 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -18,7 +18,6 @@ const TOPICS = ["functions", "types", "enums", "modules", "templates"] makedocs( sitename = "DocStringExtensions.jl", modules = [DocStringExtensions, Examples], - checkdocs_ignored_modules = [DocStringExtensions._PrecompileWorkload], clean = false, pages = Any[ "Home" => "index.md", diff --git a/src/DocStringExtensions.jl b/src/DocStringExtensions.jl index f8770c64..a2133333 100644 --- a/src/DocStringExtensions.jl +++ b/src/DocStringExtensions.jl @@ -37,7 +37,9 @@ $(EXPORTS) """ module DocStringExtensions -using PrecompileTools: @setup_workload, @compile_workload +@static if VERSION >= v"1.6" + Base.Experimental.@compiler_options compile = min optimize = 0 +end # Exports. @@ -97,8 +99,6 @@ let λ = s -> isa(s, Symbol) ? getfield(DocStringExtensions, s) : s end end -include("precompile.jl") - __init__() = (setter!(template_hook); nothing) end # module diff --git a/src/abbreviations/signatures.jl b/src/abbreviations/signatures.jl index e0cd635e..56ab52ff 100644 --- a/src/abbreviations/signatures.jl +++ b/src/abbreviations/signatures.jl @@ -76,21 +76,21 @@ function format(abbr::MethodSignatures, buf, doc) return if !isempty(groups) lines = String[] + io = IOBuffer() for group in groups, method in collapse_defaults(group, defaults) - push!( - lines, sprint( - io -> printmethod( - io, binding, func, method; - definition = abbr.definition, returns = abbr.returns, - brackets = abbr.defaults === :brackets - ) - ) + printmethod( + io, binding, func, method; + definition = abbr.definition, returns = abbr.returns, + brackets = abbr.defaults === :brackets ) + push!(lines, String(take!(io))) end println(buf) println(buf, "```julia") # A struct's typed constructor and its untyped fallback print the same signature. - foreach(line -> println(buf, line), unique(lines)) + for line in unique(lines) + println(buf, line) + end println(buf, "```\n") end end diff --git a/src/abbreviations/typedef.jl b/src/abbreviations/typedef.jl index fe9edd0c..4625ac19 100644 --- a/src/abbreviations/typedef.jl +++ b/src/abbreviations/typedef.jl @@ -46,7 +46,10 @@ function print_params(buf, object, io) isempty(object.parameters) && return env = parameter_context(object, io) print(buf, "{") - join(buf, map(p -> parameter_text(p, env), object.parameters), ", ") + for (i, p) in enumerate(object.parameters) + i > 1 && print(buf, ", ") + print(buf, parameter_text(p, env)) + end print(buf, "}") return end diff --git a/src/methods.jl b/src/methods.jl index f3604d94..447a4084 100644 --- a/src/methods.jl +++ b/src/methods.jl @@ -50,22 +50,27 @@ function methodgroups(func, typesig, modname; exact = true) end func isa Type && typesig != Union{} && exact && append!(methods, callable_methods(func, typesig)) unique!(methods) - # Group methods by file and line number. - groups = groupby(Tuple{Symbol, Int}, Vector{Method}, methods) do m - (m.file, m.line), m + # Group the methods from `modname` by file and line number. + groups = Vector{Method}[] + for m in methods + m.module == modname || continue + index = findfirst_group(groups, m) + index === nothing ? push!(groups, Method[m]) : push!(groups[index], m) end - # Filter out methods from other modules and with non-matching signatures. + # Filter out methods with non-matching signatures. typesigs = alltypesigs(typesig) results = Vector{Method}[] - for (key, group) in groups - filter!(m -> m.module == modname, group) + for group in groups if exact # Julia 1.12 and later can normalise the union of a definition's signatures # into a single type, such as `Union{Tuple{}, Tuple{Any, Vararg{Any}}} == Tuple`. # When no method matches a component, keep the group if its signatures together # equal `typesig`. - matching = filter(m -> argsig(m) in typesigs, group) + matching = Method[] + for m in group + argsig(m) in typesigs && push!(matching, m) + end if !isempty(matching) || !signatures_equal(group, typesig) group = matching end @@ -73,10 +78,29 @@ function methodgroups(func, typesig, modname; exact = true) isempty(group) || push!(results, group) end - # Sort the groups by file and line. - sort!(results, lt = comparemethods, by = first) + return insertion_sort!(results, (a, b) -> comparemethods(first(a), first(b))) +end - return results +function findfirst_group(groups, m::Method) + for i in eachindex(groups) + first(groups[i]).file === m.file && first(groups[i]).line == m.line && return i + end + return nothing +end + +# `Base.sort!` compiles a specialisation for each `lt`, while this runs interpreted with the +# rest of the package. The lists it sorts hold a few methods. +function insertion_sort!(xs, lt) + for i in 2:length(xs) + x = xs[i] + j = i - 1 + while j >= 1 && lt(x, xs[j]) + xs[j + 1] = xs[j] + j -= 1 + end + xs[j + 1] = x + end + return xs end """ @@ -91,9 +115,13 @@ function documented_groups(groups, doc) path = get(doc.data, :path, nothing)::Union{String, Nothing} line = get(doc.data, :linenumber, nothing)::Union{Int, Nothing} (path === nothing || line === nothing) && return groups - after = filter(g -> String(first(g).file) == path && first(g).line >= line, groups) - isempty(after) && return groups - return [after[argmin(map(g -> first(g).line, after))]] + nearest = nothing + for group in groups + m = first(group) + String(m.file) == path && m.line >= line || continue + (nearest === nothing || m.line < first(nearest).line) && (nearest = group) + end + return nearest === nothing ? groups : [nearest] end # The argument types of `m`, without the function's own type or a macro's hidden arguments. @@ -447,37 +475,6 @@ function alltypesigs(sig)::Vector{Any} end end -""" -$(:SIGNATURES) - -A helper method for [`groupby`](@ref) that uses a pre-allocated `groups` `Dict`. -""" -function groupby!(f, groups, data) - for each in data - key, value = f(each) - push!(get!(groups, key, []), value) - end - return sort!(collect(groups), by = first) -end - -""" -$(:SIGNATURES) - -Group `data` using function `f` where key type is specified by `K` and group type by `V`. - -The function `f` takes a single argument, an element of `data`, and should return a 2-tuple -of `(computed_key, element)`. See the example below for details. - -# Examples - -```julia -groupby(Int, Vector{Int}, collect(1:10)) do num - mod(num, 3), num -end -``` -""" -groupby(f, K, V, data) = groupby!(f, Dict{K, V}(), data) - get_method_source(m::Method) = Base.uncompressed_ast(m)::Core.CodeInfo nargs(m::Method) = m.nargs @@ -559,13 +556,28 @@ function keyword_types(method::Method, kws, vars) body = isempty(kws) ? nothing : keyword_body(method) body === nothing && return types, TypeVar[] own = typevars(body.sig) - shared = Dict(v.name => v for v in vars) - sig = isempty(own) ? body.sig : body.sig{[get(shared, v.name, v) for v in own]...} - names = get_method_source(body).slotnames[2:nargs(body)] - for (name, type) in zip(names, tuple_parameters(sig)[2:end]) - types[string(name)] = type + # A keyword type parameter named as one in `vars` is that parameter. + params = Any[] + keyword_only = TypeVar[] + for v in own + i = findlast_named(vars, v.name) + i === nothing && push!(keyword_only, v) + push!(params, i === nothing ? v : vars[i]) + end + sig = isempty(own) ? body.sig : body.sig{params...} + names = get_method_source(body).slotnames + sigtypes = tuple_parameters(sig) + for i in 2:min(nargs(body), length(sigtypes)) + types[string(names[i])] = sigtypes[i] + end + return types, keyword_only +end + +function findlast_named(vars, name::Symbol) + for i in length(vars):-1:1 + vars[i].name === name && return i end - return types, filter(v -> !haskey(shared, v.name), own) + return nothing end diff --git a/src/precompile.jl b/src/precompile.jl deleted file mode 100644 index 23c19d1a..00000000 --- a/src/precompile.jl +++ /dev/null @@ -1,66 +0,0 @@ -# Representative docstrings expanded during precompilation -module _PrecompileWorkload - - using ..DocStringExtensions - - const PtrOrRef{T} = Union{Ptr{T}, Ref{T}} - - """ - $(TYPEDEF) - - $(TYPEDFIELDS) - """ - struct Point{T <: Real} - "x" - x::T - "y" - y::T - end - - """ - $(TYPEDSIGNATURES) - """ - point(x::T, y::T = zero(T)) where {T <: Real} = Point(x, y) - - """ - $(TYPEDSIGNATURES) - """ - scale(p::Point, s::Union{Integer, AbstractFloat} = 1; round::Bool = false) = p - - """ - $(TYPEDSIGNATURES) - """ - fill!(out::PtrOrRef{Point{Float64}}, xs::Vector{Float64}, n::Integer...) = nothing - - """ - $(SIGNATURES) - - $(METHODLIST) - """ - untyped(a, b = 1; c = 2) = a - - """ - $(FUNCTIONNAME) - - $(FIELDS) - """ - mutable struct Mutable - a::Int - end - -end - -@setup_workload begin - docstrs = [docstr for multidoc in values(Docs.meta(_PrecompileWorkload)) for docstr in values(multidoc.docs)] - @compile_workload begin - # `Docs.parsedoc` is defined by the REPL stdlib, so expand the abbreviations directly. - buf = IOBuffer() - for docstr in docstrs - for part in docstr.text - if part isa Abbreviation - format(part, buf, docstr) - end - end - end - end -end diff --git a/src/printmethod.jl b/src/printmethod.jl index 68ba9721..9f60d341 100644 --- a/src/printmethod.jl +++ b/src/printmethod.jl @@ -73,10 +73,12 @@ julia> String(take!(buffer)) function printmethod(buffer::IOBuffer, binding::Docs.Binding, func, method::Method; definition = nothing, returns = false, brackets = false) aliases = alias_context(method.module) env = unionall_env(typevars(method.sig), aliases) - typestring(t) = type_text(t, env) names = argument_names(method, definition) - args = map(names, argument_types(method, length(names))) do arg, t - arg == "_" && t !== Any ? "::$(typestring(t))" : arg + types = argument_types(method, length(names)) + args = String[] + for i in eachindex(names) + arg, t = names[i], types[i] + push!(args, arg == "_" && t !== Any ? "::$(type_text(t, env))" : arg) end method.isva && (args[end] = "$(args[end])...") kws = string.(keywords(func, method)) @@ -84,13 +86,13 @@ function printmethod(buffer::IOBuffer, binding::Docs.Binding, func, method::Meth instance = instance_type(func, method) vars = String[] name = if instance === nothing - callable_name(binding, constructor_parameters(func, method), typestring) + callable_name(binding, constructor_parameters(func, method), env) elseif func === Type # The binding `Type` does not say which types the callable takes, so print them as # their module names them, with any type parameter they use. used = used_typevars(typevars(method.sig), [instance]) env = IOContext(unionall_env(used, aliases), :module => method.module) - vars = String[typevar_text(v, env) for v in used] + vars = typevar_texts(used, env) instance_call(method, type_text(instance, env)) else instance_call(method, string(binding.var)) @@ -111,10 +113,12 @@ appears in `definition`. function argument_names(method::Method, definition) args = arguments(method) matches_definition(method, definition) || return args - return map(enumerate(args)) do (i, arg) + names = String[] + for i in eachindex(args) name, _ = definition.positional[i] - arg == "_" ? name : arg + push!(names, args[i] == "_" ? name : args[i]) end + return names end """ @@ -128,9 +132,12 @@ function matches_definition(method::Method, definition::DefinitionArguments) names = arguments(method) length(names) <= length(definition.positional) || return false # `_` stands for an argument whose name one side does not know, such as `@nospecialize(x)`. - return all(zip(names, definition.positional)) do (name, (expected, _)) - name == "_" || expected == "_" || name == expected + for i in eachindex(names) + name = names[i] + expected, _ = definition.positional[i] + name == "_" || expected == "_" || name == expected || return false end + return true end """ @@ -143,16 +150,20 @@ and leave `kws` unchanged. Leaves both unchanged when `method` does not match `d """ function append_defaults(args, kws, kwnames, method::Method, definition; brackets::Bool = false) matches_definition(method, definition) || return args, kws - args = map(enumerate(args)) do (i, arg) + with_defaults = String[] + for i in eachindex(args) + arg = args[i] _, default = definition.positional[i] - default === nothing ? arg : brackets ? "[$arg]" : "$arg=$default" + push!(with_defaults, default === nothing ? arg : brackets ? "[$arg]" : "$arg=$default") end - brackets && return args, kws - kws = map(kws, kwnames) do kw, name - default = get(definition.keywords, name, nothing) - default === nothing ? kw : "$kw=$default" + brackets && return with_defaults, kws + kws_with_defaults = String[] + for i in eachindex(kws) + kw = kws[i] + default = get(definition.keywords, kwnames[i], nothing) + push!(kws_with_defaults, default === nothing ? kw : "$kw=$default") end - return args, kws + return with_defaults, kws_with_defaults end """ @@ -197,7 +208,6 @@ function printmethod(buffer::IOBuffer, binding::Docs.Binding, func, method::Meth used = used_typevars([vars; kwvars], types) aliases = alias_context(method.module) env = unionall_env(used, aliases) - typestring(t) = type_text(t, env) argtypes = collect(body.types) if expanded_vararg(method) @@ -206,19 +216,23 @@ function printmethod(buffer::IOBuffer, binding::Docs.Binding, func, method::Meth argtypes = Any[argtypes[1:(end - n)]..., Vararg{n > 0 ? argtypes[end] : Any, n}] end - args = map((arg, t) -> typed_argument(arg, t, typestring), args, argtypes) - kws = map(kwnames) do kw + typed = String[] + for i in eachindex(args) + push!(typed, typed_argument(args[i], argtypes[i], env)) + end + kws = String[] + for kw in kwnames t = get(kwtypes, kw, Any) - t === Any ? kw : "$kw::$(typestring(t))" + push!(kws, t === Any ? kw : "$kw::$(type_text(t, env))") end - args, kws = append_defaults(args, kws, kwnames, method, definition; brackets = brackets) + args, kws = append_defaults(typed, kws, kwnames, method, definition; brackets = brackets) return printmethod_format( buffer, - instance === nothing ? callable_name(binding, params, typestring) : instance_call(method, typestring(instance)), + instance === nothing ? callable_name(binding, params, env) : instance_call(method, type_text(instance, env)), args, kws; - where_clause = where_clause([typevar_text(v, env) for v in used]), + where_clause = where_clause(typevar_texts(used, env)), return_type = return_annotation(returns, func, method, typesig), macrocall = ismacro(method) ) @@ -228,18 +242,30 @@ end function used_typevars(vars, types) # Innermost first: a type parameter used only in a later parameter's bound is still used. used = Any[] - for v in reverse(vars) + for i in length(vars):-1:1 + v = vars[i] isanonymous(v) && continue - if any(t -> Base.has_typevar(t, v), types) || - any(u -> Base.has_typevar(u.lb, v) || Base.has_typevar(u.ub, v), used) - pushfirst!(used, v) - end + (has_typevar_in(types, v) || bounds_have_typevar(used, v)) && pushfirst!(used, v) end return used end -# The positional argument `arg` of type `t`, as the typed `printmethod` prints it. -function typed_argument(arg, t, typestring) +function has_typevar_in(types, v::TypeVar) + for t in types + Base.has_typevar(t, v) && return true + end + return false +end + +function bounds_have_typevar(vars, v::TypeVar) + for u in vars + (Base.has_typevar(u.lb, v) || Base.has_typevar(u.ub, v)) && return true + end + return false +end + +# The positional argument `arg` of type `t`, as the typed `printmethod` prints it in `io`. +function typed_argument(arg, t, io) type = "" suffix = "" if Base.isvarargtype(t) @@ -248,11 +274,11 @@ function typed_argument(arg, t, typestring) if len === nothing suffix = "..." else - type = "::Vararg{$(typestring(t)), $len}" + type = "::Vararg{$(type_text(t, io)), $len}" end end if t !== Any && isempty(type) - type = "::$(typestring(t))" + type = "::$(type_text(t, io))" end # An unnamed argument with a type prints as `::T`. if arg == "_" && !isempty(type) @@ -290,9 +316,23 @@ where_clause(typevars) = length(typevars) == 1 ? " where $(typevars[1])" : " where {$(join(typevars, ", "))}" +function typevar_texts(vars, io) + texts = String[] + for v in vars + push!(texts, typevar_text(v, io)) + end + return texts +end + # The name `method` is called by, with the parameters of a constructor written as `T{P}(...)`. -callable_name(binding, params, typestring) = - params === nothing ? string(binding.var) : "$(binding.var){$(join(map(typestring, params), ", "))}" +function callable_name(binding, params, io) + params === nothing && return string(binding.var) + texts = String[] + for p in params + push!(texts, type_text(p, io)) + end + return "$(binding.var){$(join(texts, ", "))}" +end # The instance a callable method is called on, `(e::T)` or `(::T)`, with `type` printed for `T`. function instance_call(method::Method, type) diff --git a/src/type_text.jl b/src/type_text.jl index 88560b25..a02a5138 100644 --- a/src/type_text.jl +++ b/src/type_text.jl @@ -51,14 +51,22 @@ end # `t` as `show` prints it in `io`, with each free type variable of `names` written as its text. function named_text(@nospecialize(t), names, io::IO) - text = sprint(show, t; context = unionall_env(map(first, names), io)) + text = sprint(show, t; context = unionall_env(placeholders(names), io)) # Last first, so that the first placeholder is not replaced inside the tenth. - for (v, replacement) in reverse(names) - text = replace(text, string(v.name) => replacement) + for i in length(names):-1:1 + text = replace(text, string(names[i].first.name) => names[i].second) end return text end +function placeholders(names) + vars = TypeVar[] + for name in names + push!(vars, name.first) + end + return vars +end + # `t` with each anonymous type variable that `show` prints by its generated name in `io` replaced # by a free one that `show` prints by its name when it is in the `:unionall_env`. Appends each # replacement to `names` with its text: its bound when `writes_as_bound`, as in `<:Real`. @@ -77,7 +85,7 @@ function name_anonymous!(names, @nospecialize(t), io::IO) push!(names, v => bound_text(v, io)) return name_anonymous!(names, t{v}, io) end - text = alias_text(t, unionall_env(map(first, names), io)) + text = alias_text(t, unionall_env(placeholders(names), io)) text === nothing && return t # A placeholder for the whole `UnionAll`: Julia caches a type such as `Ref{X}` by type # equality, so `Ref{X}` rebuilt from a renamed `X` comes back with the generated name. @@ -87,8 +95,14 @@ function name_anonymous!(names, @nospecialize(t), io::IO) elseif t isa Union return Union{name_anonymous!(names, t.a, io), name_anonymous!(names, t.b, io)} elseif t isa DataType - params = Any[name_anonymous!(names, p, io) for p in t.parameters] - return all(map(===, params, collect(t.parameters))) ? t : t.name.wrapper{params...} + params = Any[] + changed = false + for p in t.parameters + named = name_anonymous!(names, p, io) + changed |= named !== p + push!(params, named) + end + return changed ? t.name.wrapper{params...} : t elseif istypeoftype(t) # From Julia 1.14 `Type{T}` is a `TypeEq`, not a `DataType`. p = name_anonymous!(names, type_parameter(t), io) @@ -116,7 +130,13 @@ function writes_as_bound(t::UnionAll) end istypeoftype(body) && return type_parameter(body) === v body isa DataType || return false - return count(p -> p === v, body.parameters) == 1 && count(p -> Base.has_typevar(p, v), body.parameters) == 1 + identical = 0 + containing = 0 + for p in body.parameters + identical += p === v + containing += Base.has_typevar(p, v) + end + return identical == 1 && containing == 1 end # `io` extended so that `type_text` also looks for aliases in `mod`, the module that defines @@ -143,9 +163,10 @@ function alias_text(t::UnionAll, io::IO) best === nothing && return nothing _, mod, name, args = best anonymous, _ = leading_anonymous(t) - texts = map(args) do arg - i = findfirst(v -> v === arg, anonymous) - i === nothing ? type_text(arg, io) : bound_text(anonymous[i], io) + texts = String[] + for arg in args + i = findfirst_identical(arg, anonymous) + push!(texts, i === nothing ? type_text(arg, io) : bound_text(anonymous[i], io)) end return "$(alias_name(mod, name, io)){$(join(texts, ", "))}" end @@ -180,7 +201,9 @@ function foreach_type(f, @nospecialize(t)) foreach_type(f, t.a) foreach_type(f, t.b) elseif t isa DataType - foreach(p -> foreach_type(f, p), t.parameters) + for p in t.parameters + foreach_type(f, p) + end elseif istypeoftype(t) foreach_type(f, type_parameter(t)) elseif Base.isvarargtype(t) @@ -195,7 +218,10 @@ function shows_anonymous(@nospecialize(t), text, io::IO) anonymous = Symbol[] foreach_type(part -> part isa UnionAll && isanonymous(part.var, io) && push!(anonymous, part.var.name), t) # Whole names only: `_A` also appears inside a name such as `MAX_A`. - return any(name -> occursin(Regex("(? Base.invokelatest(isalias, mod, name), - Base.invokelatest(names, mod; all = true, imported = true) -) +function module_aliases(mod::Module) + aliases = Symbol[] + for name in Base.invokelatest(names, mod; all = true, imported = true)::Vector{Symbol} + Base.invokelatest(isalias, mod, name)::Bool && push!(aliases, name) + end + return aliases +end # Whether `name` is a `const` of `mod` bound to a parametric alias, such as `Vector` or # `const ScalarOrRef{T} = Union{T, Ref{T}} where T`, and not to a type's own `UnionAll`. @@ -233,23 +262,37 @@ function alias_arguments(@nospecialize(alias), t::UnionAll) vars = typevars(alias) args = Vector{Any}(undef, length(vars)) match_alias!(args, vars, Base.unwrap_unionall(alias), body) || return nothing - all(i -> isassigned(args, i), eachindex(args)) || return nothing + for i in eachindex(args) + isassigned(args, i) || return nothing + end for v in anonymous - count(arg -> arg === v, args) == 1 || return nothing - any(arg -> arg !== v && Base.has_typevar(arg, v), args) && return nothing + identical = 0 + for arg in args + identical += arg === v + arg !== v && Base.has_typevar(arg, v) && return nothing + end + identical == 1 || return nothing end # The match reads `t` structurally, so only an alias that rebuilds `t` gives it. Julia # applies an alias to a type variable outside its bounds without complaint, so `t` must also # be a subtype of the alias. - fresh = Dict{TypeVar, TypeVar}(v => TypeVar(v.name, v.lb, v.ub) for v in anonymous) + fresh = TypeVar[] + for v in anonymous + push!(fresh, TypeVar(v.name, v.lb, v.ub)) + end + freshargs = Any[] + for arg in args + i = findfirst_identical(arg, anonymous) + push!(freshargs, i === nothing ? arg : fresh[i]) + end applied = try - alias{Any[arg isa TypeVar ? get(fresh, arg, arg) : arg for arg in args]...} + alias{freshargs...} catch err err isa TypeError || rethrow() return nothing end - for v in reverse(anonymous) - applied = UnionAll(fresh[v], applied) + for i in length(fresh):-1:1 + applied = UnionAll(fresh[i], applied) end return applied == t && t <: alias ? args : nothing end @@ -269,7 +312,7 @@ end # `pattern` reads as `t` with those bindings. A variable bound twice must bind equal parts. function match_alias!(args, vars, @nospecialize(pattern), @nospecialize(t)) if pattern isa TypeVar - i = findfirst(v -> v === pattern, vars) + i = findfirst_identical(pattern, vars) i === nothing && return pattern === t isassigned(args, i) && return same_parameter(args[i], t) args[i] = t @@ -280,17 +323,24 @@ function match_alias!(args, vars, @nospecialize(pattern), @nospecialize(t)) match_alias!(args, vars, pattern.var.ub, t.var.ub) || return false # The variable `pattern` binds stands for the one `t` binds, within the body. inner = Vector{Any}(undef, length(args) + 1) - foreach(i -> isassigned(args, i) && (inner[i] = args[i]), eachindex(args)) + for i in eachindex(args) + isassigned(args, i) && (inner[i] = args[i]) + end inner[end] = t.var match_alias!(inner, [vars; pattern.var], pattern.body, t.body) || return false - foreach(i -> isassigned(inner, i) && (args[i] = inner[i]), eachindex(args)) + for i in eachindex(args) + isassigned(inner, i) && (args[i] = inner[i]) + end return true elseif pattern isa Union return t isa Union && match_alias!(args, vars, pattern.a, t.a) && match_alias!(args, vars, pattern.b, t.b) elseif pattern isa DataType t isa DataType && pattern.name === t.name && length(pattern.parameters) == length(t.parameters) || return false - return all(i -> match_alias!(args, vars, pattern.parameters[i], t.parameters[i]), eachindex(pattern.parameters)) + for i in eachindex(pattern.parameters) + match_alias!(args, vars, pattern.parameters[i], t.parameters[i]) || return false + end + return true elseif istypeoftype(pattern) return istypeoftype(t) && match_alias!(args, vars, type_parameter(pattern), type_parameter(t)) elseif Base.isvarargtype(pattern) @@ -304,5 +354,17 @@ end # Whether two type parameters are the same: identical, or equal types. same_parameter(@nospecialize(a), @nospecialize(b)) = a === b || a isa Type && b isa Type && a == b +function findfirst_identical(@nospecialize(x), xs) + for i in eachindex(xs) + xs[i] === x && return i + end + return nothing +end + # `io` extended so that `show` prints each of `typevars` by name. -unionall_env(typevars, io = IOContext(devnull)) = foldl((io, v) -> IOContext(io, :unionall_env => v), typevars; init = io) +function unionall_env(typevars, io = IOContext(devnull)) + for v in typevars + io = IOContext(io, :unionall_env => v) + end + return io +end diff --git a/test/methods.jl b/test/methods.jl index dfdf79f7..c5746c5e 100644 --- a/test/methods.jl +++ b/test/methods.jl @@ -149,18 +149,6 @@ @test DSE.alltypesigs(Tuple{G} where {G}) == Any[Tuple{G} where {G}] end - @testset "groupby" begin - let groups = DSE.groupby(Int, Vector{Int}, collect(1:10)) do each - mod(each, 3), each - end - @test groups == Pair{Int, Vector{Int}}[ - 0 => [3, 6, 9], - 1 => [1, 4, 7, 10], - 2 => [2, 5, 8], - ] - end - end - @testset "comparemethods" begin let f = first(methods(M.f)), g = first(methods(M.g)) diff --git a/test/printmethod.jl b/test/printmethod.jl index 82427b8f..e978b187 100644 --- a/test/printmethod.jl +++ b/test/printmethod.jl @@ -191,15 +191,15 @@ end @testset "typed_argument" begin - typestring(t) = sprint(show, t) + io = IOContext(devnull) unbounded = last(Tuple{Vararg{Int8}}.parameters) counted = last((Tuple{Vararg{Int8, N}} where {N}).body.parameters) - @test DSE.typed_argument("x", Any, typestring) == "x" - @test DSE.typed_argument("x", Int8, typestring) == "x::Int8" - @test DSE.typed_argument("_", Int8, typestring) == "::Int8" - @test DSE.typed_argument("_", Any, typestring) == "_" - @test DSE.typed_argument("xs", unbounded, typestring) == "xs::Int8..." - @test DSE.typed_argument("_", unbounded, typestring) == "::Int8..." - @test DSE.typed_argument("xs", counted, typestring) == "xs::Vararg{Int8, N}" + @test DSE.typed_argument("x", Any, io) == "x" + @test DSE.typed_argument("x", Int8, io) == "x::Int8" + @test DSE.typed_argument("_", Int8, io) == "::Int8" + @test DSE.typed_argument("_", Any, io) == "_" + @test DSE.typed_argument("xs", unbounded, io) == "xs::Int8..." + @test DSE.typed_argument("_", unbounded, io) == "::Int8..." + @test DSE.typed_argument("xs", counted, io) == "xs::Vararg{Int8, N}" end end From 0a5bcfd9c3ce5944462992003468d80b68fffd14 Mon Sep 17 00:00:00 2001 From: MichaelHatherly Date: Wed, 21 Oct 2026 11:42:38 +0100 Subject: [PATCH 2/2] perf: interpret with compile=min only and loop over the remaining closures `optimize=0` changed neither load time, first render nor the CImGui.jl docs build, so the module sets `compile=min` alone. A trace of the first render across every abbreviation still showed Base compiling for DSE: - `ComposedFunction` calls in `INSTANCES` and `SUBTYPES` - closures on the signature and name-list paths, such as `collapse_defaults` and `print_name_list` - `format(::ModuleImports)` itself, which Julia compiles because it holds a `ccall` Those are loops now, and each `ccall` sits in a function of its own. The first render of every abbreviation drops from 309 ms to 170 ms on nightly. `AGENTS.md` records the rule so new code keeps to it. --- AGENTS.md | 1 + src/DocStringExtensions.jl | 3 +- src/abbreviations/fields.jl | 5 +- src/abbreviations/imports.jl | 10 ++- src/abbreviations/instances.jl | 12 +++- src/abbreviations/module_names.jl | 8 ++- src/abbreviations/signatures.jl | 23 +++++-- src/abbreviations/subtypes.jl | 3 +- src/methods.jl | 106 ++++++++++++++++++++++-------- 9 files changed, 125 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b80460ae..f4548d40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ DocStringExtensions adds abbreviations and templates to Julia's docsystem. An ab - CI runs Julia 1.0, the LTS, the latest release, and nightly. Code in `src/` and `test/` must parse and run on 1.0, so check any syntax or Base function newer than that against the floor. - The package leans on undocumented Base internals (`Base.kwarg_decl`, method table fields, binding partitions on 1.12+). The `Base assumptions` testset in `test/base_assumptions.jl` records each one. Add a case there when the code starts relying on another. - A user-facing change updates every page that shows the feature, in the same commit as the code. Check the docstring, the `Examples` module in `docs/Examples/src/` and its page in `docs/src/examples/`, `docs/src/setup.md`, and `CHANGELOG.md`. Search `docs/` for the names the change touches, then build the docs as CI does. The build runs the doctests and fails on a broken `@ref`. +- From Julia 1.6 the package runs interpreted (`@compiler_options compile = min` in `src/DocStringExtensions.jl`), and it ships no precompile workload, which would slow every dependent's load. Interpreted code costs nothing to compile, but a closure or generator handed to Base, as in `map`, `filter`, `any`, `sort!` with `by` or a comprehension, compiles a new specialization of the Base function on the first render. Write those as loops. - `format` methods can run in a stale world during macro expansion. Wrap binding lookups such as `Docs.resolve` and `methods` in `Base.invokelatest`, as the existing `format` methods do. ## Running tests diff --git a/src/DocStringExtensions.jl b/src/DocStringExtensions.jl index a2133333..959f58dc 100644 --- a/src/DocStringExtensions.jl +++ b/src/DocStringExtensions.jl @@ -37,8 +37,9 @@ $(EXPORTS) """ module DocStringExtensions +# A docstring renders once per session, so interpreting the package costs less than compiling it. @static if VERSION >= v"1.6" - Base.Experimental.@compiler_options compile = min optimize = 0 + Base.Experimental.@compiler_options compile = min end # Exports. diff --git a/src/abbreviations/fields.jl b/src/abbreviations/fields.jl index 4a69f344..7921ac27 100644 --- a/src/abbreviations/fields.jl +++ b/src/abbreviations/fields.jl @@ -128,7 +128,10 @@ first_line(text) = occursin('\n', text) ? "$(first(split(text, '\n'))) …" : te # Julia's Markdown reads an even run of backticks as LaTeX, so the fence is the shortest odd # run longer than any in `text`. A space keeps a backtick at either end out of the fence. function code_span(text) - longest = reduce(max, [length(m.match) for m in eachmatch(r"`+", text)]; init = 0) + longest = 0 + for m in eachmatch(r"`+", text) + longest = max(longest, length(m.match)) + end fence = "`"^(isodd(longest) ? longest + 2 : longest + 1) padding = startswith(text, '`') || endswith(text, '`') ? " " : "" return "$fence$padding$text$padding$fence" diff --git a/src/abbreviations/imports.jl b/src/abbreviations/imports.jl index 8ecd9087..3a2da1d6 100644 --- a/src/abbreviations/imports.jl +++ b/src/abbreviations/imports.jl @@ -28,13 +28,19 @@ julia> @doc Plots """ const IMPORTS = ModuleImports() +# Kept out of `format`: Julia compiles any method holding a `ccall`, even in an interpreted module. +module_usings(mod::Module) = ccall(:jl_module_usings, Any, (Any,), mod)::Vector{Any} + function format(::ModuleImports, buf, doc) binding = doc_binding(doc) object = Base.invokelatest(Docs.resolve, binding)::Module - imports = unique(Vector{Module}(ccall(:jl_module_usings, Any, (Any,), object)::Vector{Any})) + imports = Module[] + for mod in module_usings(object) + mod in imports || push!(imports, mod) + end return if !isempty(imports) println(buf) - for mod in sort(imports, by = string) + for mod in insertion_sort!(imports, (a, b) -> string(a) < string(b)) println(buf, " - `", mod, "`") end println(buf) diff --git a/src/abbreviations/instances.jl b/src/abbreviations/instances.jl index cd979783..0902e98f 100644 --- a/src/abbreviations/instances.jl +++ b/src/abbreviations/instances.jl @@ -28,7 +28,8 @@ julia> @doc Fruit const INSTANCES = EnumInstances() function format(::EnumInstances, buf, doc) - entries = Base.invokelatest(enum_entries ∘ Docs.resolve, doc_binding(doc))::Vector{Tuple{String, String}} + enum = Base.invokelatest(Docs.resolve, doc_binding(doc)) + entries = Base.invokelatest(enum_entries, enum)::Vector{Tuple{String, String}} if !isempty(entries) println(buf) for (name, value) in entries @@ -39,8 +40,13 @@ function format(::EnumInstances, buf, doc) return nothing end -enum_entries(enum::Type{<:Base.Enum}, prefix = "") = - [("$prefix$(Symbol(instance))", string(Integer(instance))) for instance in instances(enum)] +function enum_entries(enum::Type{<:Base.Enum}, prefix = "") + entries = Tuple{String, String}[] + for instance in instances(enum) + push!(entries, ("$prefix$(Symbol(instance))", string(Integer(instance)))) + end + return entries +end # EnumX.jl documents the module that holds its enum type `T`, and callers write `Module.Name`. enum_entries(mod::Module) = isdefined(mod, :T) ? enum_entries(mod.T, "$(nameof(mod)).") : Tuple{String, String}[] enum_entries(other, prefix = "") = Tuple{String, String}[] diff --git a/src/abbreviations/module_names.jl b/src/abbreviations/module_names.jl index c12276ab..5a287c60 100644 --- a/src/abbreviations/module_names.jl +++ b/src/abbreviations/module_names.jl @@ -38,14 +38,18 @@ const EXPORTS = ModuleExports() function format(::ModuleExports, buf, doc) object = Base.invokelatest(Docs.resolve, doc_binding(doc))::Module # `names` also returns `public` names on Julia 1.11 and later. - return print_name_list(buf, object, filter(sym -> Base.invokelatest(Base.isexported, object, sym)::Bool, names(object))) + exported = Symbol[] + for sym in names(object) + Base.invokelatest(Base.isexported, object, sym)::Bool && push!(exported, sym) + end + return print_name_list(buf, object, exported) end function print_name_list(buf, object::Module, syms) if !isempty(syms) println(buf) # Sorting ignores the `@` in macro names and sorts them in with others. - for sym in sort(syms, by = s -> lstrip(string(s), '@')) + for sym in insertion_sort!(copy(syms), (a, b) -> lstrip(string(a), '@') < lstrip(string(b), '@')) # Skip the module itself, since that's always exported. sym === nameof(object) && continue # We print linked names using Documenter.jl cross-reference syntax diff --git a/src/abbreviations/signatures.jl b/src/abbreviations/signatures.jl index 56ab52ff..10686c33 100644 --- a/src/abbreviations/signatures.jl +++ b/src/abbreviations/signatures.jl @@ -181,12 +181,18 @@ the method that takes every argument of `definition`. """ collapse_defaults(group, ::Nothing) = group function collapse_defaults(group, definition::DefinitionArguments) - matching = filter(m -> matches_definition(m, definition), group) - isempty(matching) && return group - longest = maximum(m -> length(arguments(m)), matching) - return filter(group) do m - !matches_definition(m, definition) || length(arguments(m)) == longest + longest = nothing + for m in group + matches_definition(m, definition) || continue + n = length(arguments(m)) + (longest === nothing || n > longest) && (longest = n) end + longest === nothing && return group + kept = Method[] + for m in group + (!matches_definition(m, definition) || length(arguments(m)) == longest) && push!(kept, m) + end + return kept end function format(tms::TypedMethodSignatures, buf, doc) @@ -227,9 +233,12 @@ function printed_typesig(func, method::Method, tuples) if idx === nothing n = length(arguments(method)) @static if Sys.iswindows() && VERSION < v"1.8" - idx = findlast(t -> has_arity(t, n), tuples) + candidates = length(tuples):-1:1 else - idx = findfirst(t -> has_arity(t, n), tuples) + candidates = 1:length(tuples) + end + for i in candidates + has_arity(tuples[i], n) && (idx = i; break) end end # Julia 1.12 and later can normalise `typesig` to `Tuple`, leaving no tuple of length `n`. diff --git a/src/abbreviations/subtypes.jl b/src/abbreviations/subtypes.jl index dd56121c..be207612 100644 --- a/src/abbreviations/subtypes.jl +++ b/src/abbreviations/subtypes.jl @@ -36,7 +36,8 @@ julia> @doc Shape const SUBTYPES = TypeSubtypes() function format(::TypeSubtypes, buf, doc) - names = Base.invokelatest(subtype_names ∘ Docs.resolve, doc_binding(doc))::Vector{String} + object = Base.invokelatest(Docs.resolve, doc_binding(doc)) + names = Base.invokelatest(subtype_names, object)::Vector{String} if !isempty(names) println(buf) for name in names diff --git a/src/methods.jl b/src/methods.jl index 447a4084..046d28ab 100644 --- a/src/methods.jl +++ b/src/methods.jl @@ -11,12 +11,14 @@ function struct_constructors(func, definition::StructDefinition, modname) if func isa UnionAll candidates = unique([candidates; constructor_methods(func, Tuple{Vararg{Any}})]) end - found = filter(candidates) do m - m.module == modname || return false - String(m.file) == definition.file && return m.line <= definition.last_line - return definition.wrapped && generated_constructor(func, m) + found = Method[] + for m in candidates + m.module == modname || continue + constructor = String(m.file) == definition.file ? m.line <= definition.last_line : + definition.wrapped && generated_constructor(func, m) + constructor && push!(found, m) end - return sort!(found; by = m -> (length(arguments(m)), string(m.sig))) + return insertion_sort!(found, (a, b) -> isless((length(arguments(a)), string(a.sig)), (length(arguments(b)), string(b.sig)))) end # The keyword constructor `Base.@kwdef` and similar macros generate, and the positional one @@ -139,7 +141,12 @@ function ismacro(m::Method) return startswith(String(m.name), "@") && length(params) >= 3 && params[2] === LineNumberNode && params[3] === Module end -ismacro(@nospecialize(f::Function)) = any(ismacro, Base.invokelatest(methods, f)::Base.MethodList) +function ismacro(@nospecialize(f::Function)) + for m in Base.invokelatest(methods, f)::Base.MethodList + ismacro(m) && return true + end + return false +end # The argument types `f` is called with for those in `sig`, which a macro's docstring gives as # its caller writes them. @@ -157,12 +164,22 @@ arguments, up to one past the most parameters any tuple declares. Larger counts varargs and compare the same. """ function signatures_equal(group, typesig) - sigs = map(argsig, group) + sigs = Any[] + for m in group + push!(sigs, argsig(m)) + end combined = Union{sigs...} - longest = mapreduce(t -> length(tuple_parameters(t)), max, [alltypesigs(typesig); sigs]; init = 0) - return all(0:(longest + 1)) do n - typeintersect(typesig, NTuple{n, Any}) == typeintersect(combined, NTuple{n, Any}) + longest = 0 + for t in sigs + longest = max(longest, length(tuple_parameters(t))) + end + for t in alltypesigs(typesig) + longest = max(longest, length(tuple_parameters(t))) end + for n in 0:(longest + 1) + typeintersect(typesig, NTuple{n, Any}) == typeintersect(combined, NTuple{n, Any}) || return false + end + return true end """ @@ -217,10 +234,12 @@ end # Sets `params[i]` to the part of `t` in the place of `vars[i]` within `pattern`. function match_parameters!(params, vars, pattern, t) if pattern isa TypeVar - i = findfirst(v -> v === pattern, vars) + i = findfirst_identical(pattern, vars) i === nothing || (params[i] = t) elseif pattern isa DataType && t isa DataType && pattern.name === t.name - foreach((p, q) -> match_parameters!(params, vars, p, q), pattern.parameters, t.parameters) + for i in 1:min(length(pattern.parameters), length(t.parameters)) + match_parameters!(params, vars, pattern.parameters[i], t.parameters[i]) + end end return params end @@ -283,12 +302,15 @@ every method that matches, so a more specific method whose signature overlaps `m skipped by its position in `methods(func, typesig)`. """ function method_return_type(func, method::Method, typesig) - matching = collect(Base.invokelatest(methods, func, typesig)::Base.MethodList) - idx = findfirst(m -> m === method, matching) + matching = Method[] + for m in Base.invokelatest(methods, func, typesig)::Base.MethodList + push!(matching, m) + end + idx = findfirst_identical(method, matching) idx === nothing && return signature_return_type(method, typesig) # Inference cannot run a generator on abstract argument types: Julia 1.0 throws and 1.10 # hits a `BoundsError` in Base. Other versions infer `Any` for a generated method itself. - any(hasgenerator, matching) && !isconcretetype(typesig) && return [] + any_generated(matching) && !isconcretetype(typesig) && return [] return Any[(Base.invokelatest(Base.return_types, func, typesig)::Vector{Any})[idx]] end @@ -301,14 +323,24 @@ function signature_return_type(method::Method, typesig) tt = typeintersect(method.sig, Base.rewrap_unionall(Tuple{Any, args.parameters...}, typesig)) tt === Union{} && return [] world = Base.get_world_counter() - matching = Any[match.method for match in Base.invokelatest(Base._methods_by_ftype, tt, -1, world)] - idx = findfirst(m -> m === method, matching) + matching = Method[] + for match in Base.invokelatest(Base._methods_by_ftype, tt, -1, world) + push!(matching, match.method) + end + idx = findfirst_identical(method, matching) idx === nothing && return [] - any(hasgenerator, matching) && !isconcretetype(tt) && return [] + any_generated(matching) && !isconcretetype(tt) && return [] typed = Base.invokelatest(Base.code_typed_by_type, tt; optimize = false, world = world) return Any[last(typed[idx])] end +function any_generated(methods) + for m in methods + hasgenerator(m) && return true + end + return false +end + if isdefined(Base, :hasgenerator) const hasgenerator = Base.hasgenerator else @@ -422,11 +454,17 @@ The methods matching the full call signature `tt`, a `Tuple` type of the callabl the argument types. """ function matched_methods(tt) - world = ccall(:jl_get_world_counter, UInt, ()) + found = Method[] # Julia 1.0 gives each match as an `svec` holding the method third. - return Method[m isa Core.SimpleVector ? m[3] : m.method for m in Base._methods_by_ftype(tt, -1, world)] + for m in Base._methods_by_ftype(tt, -1, world_counter()) + push!(found, m isa Core.SimpleVector ? m[3] : m.method) + end + return found end +# Julia compiles any method holding a `ccall`, even in an interpreted module. +world_counter() = ccall(:jl_get_world_counter, UInt, ()) + """ $(:SIGNATURES) @@ -466,10 +504,11 @@ function alltypesigs(sig)::Vector{Any} elseif isa(sig, Union) uniontypes(sig) elseif isa(sig, UnionAll) - Any[ - Base.rewrap_unionall(usig, sig) for - usig in uniontypes(Base.unwrap_unionall(sig)) - ] + sigs = Any[] + for usig in uniontypes(Base.unwrap_unionall(sig)) + push!(sigs, Base.rewrap_unionall(usig, sig)) + end + sigs else Any[sig] end @@ -509,13 +548,18 @@ function keywords(func, m::Method) if !isa(kwargs, Vector) || isempty(kwargs) return Symbol[] end - filter!(arg -> !occursin("#", string(arg)), kwargs) + named = Symbol[] + index = nothing + for arg in kwargs + occursin("#", string(arg)) && continue + push!(named, arg) + index === nothing && endswith(string(arg), "...") && (index = length(named)) + end # Keywords *may* not be sorted correctly. We move the vararg one to the end. - index = findfirst(arg -> endswith(string(arg), "..."), kwargs) if index != nothing - kwargs[index], kwargs[end] = kwargs[end], kwargs[index] + named[index], named[end] = named[end], named[index] end - return kwargs + return named end """ @@ -595,7 +639,11 @@ args = arguments(first(methods(f))) ``` """ function arguments(m::Method) - return String[isunnamed(arg) ? "_" : string(arg) for arg in slot_names(m)[(2 + hidden_arguments(m)):end]] + names = String[] + for arg in slot_names(m)[(2 + hidden_arguments(m)):end] + push!(names, isunnamed(arg) ? "_" : string(arg)) + end + return names end # The names of the argument slots of `m`, the callable itself first. Empty when `m` records none.