diff --git a/CLAUDE.md b/CLAUDE.md index 4174d36..565d1bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,17 @@ julia --project=/tmp/runic -e 'using Pkg; Pkg.add("Runic"); using Runic; Runic.m (runtime > function > package > `[Cached]` > built-in), and `set_cache_preferences!`. - `src/api.jl`: `cache_info`, `empty_caches!`, `set_cache_size!`, and the `cache_dashboard` and `enable_cache_timers!`/`disable_cache_timers!` stubs with their error hints (registered in `__init__`). +- `src/disk.jl`: `DiskCacheStyle` (default `NoCache()`) and `DiskCache{S}` (serializer type `S`), + `diskversion`, `disk_artifact`, the process-wide switch, and `_diskcall`, the miss function of + `get!` under a `DiskCache`; the lookup and management functions are stubs for the SQLite extension. +- `ext/CachedSQLiteExt.jl`: the disk caches. Per function type an optional read-only artifact and + one SQLite database per node (WAL, `keyhash`+`key` → `value`), opened on first use; disk errors + warn once and the call continues. Lookup order: RAM, artifact, node database, compute. - `ext/CachedTachikomaExt.jl`: the dashboard. It renders from a copy of the statistics, refreshed - every `interval`; tests render it headlessly with Tachikoma's `TestBackend`. + every `interval`; tests render it headlessly with Tachikoma's `TestBackend`. Two tabs (`Tab` + structs, Tachikoma's `TabBar`): RAM, and Disk, whose rows come from `Cached.disk_cache_stats()` + (in-memory counters of the SQLite extension) and whose entries and sizes (`disk_cache_info`, + a `count(*)`) are read for all rows on a background task while it is shown, at most every 10 s. - `ext/CachedTimerOutputsExt.jl`: `enable_cache_timers!(M)` evals a method of `instrument` for the owner of `M` into the extension, `disable_cache_timers!(M)` deletes it (deleting an overwritten method would revive the old one, so enabling deletes before defining). diff --git a/Project.toml b/Project.toml index 02bba3a..fb293ef 100644 --- a/Project.toml +++ b/Project.toml @@ -9,18 +9,27 @@ projects = ["test", "docs", "benchmark"] [deps] ExprTools = "e2ba6199-217a-4e67-a87a-7c52f15ade04" Preferences = "21216c6a-2e73-6563-6e65-726566657250" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +Scratch = "6c6a2e73-6563-6170-7368-637461726353" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" [weakdeps] +SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" [extensions] +CachedSQLiteExt = "SQLite" CachedTachikomaExt = "Tachikoma" CachedTimerOutputsExt = "TimerOutputs" [compat] ExprTools = "0.1" Preferences = "1.4" +SHA = "0.7, 1" +SQLite = "1.6" +Scratch = "1.2" +Serialization = "1" Tachikoma = "2.7" TimerOutputs = "0.5, 1" julia = "1.10" diff --git a/README.md b/README.md index 7b222e3..953f857 100644 --- a/README.md +++ b/README.md @@ -15,34 +15,45 @@ Transparent, strategy-aware memoization for Julia functions. ```julia using Cached -@cached function fusion(a, b; normalize = true) +@cached function combine(a, b; normalize = true) # expensive computation end -fusion(1, 2) # computed -fusion(1, 2) # looked up in fusion's cache; the result is still inferred -uncached(fusion, 1, 2) # bypasses the cache +combine(1, 2) # computed +combine(1, 2) # looked up in combine's cache; the result is still inferred +uncached(combine, 1, 2) # bypasses the cache # choose the strategy per function and argument type -Cached.CacheStyle(::typeof(fusion), a::Int, b::Int) = TaskLocalCache{LRU}() +Cached.CacheStyle(::typeof(combine), a::Int, b::Int) = TaskLocalCache{LRU}() -cache_info(fusion) # hit/miss statistics +cache_info(combine) # hit/miss statistics cache_info(MyPackage) # ... of all cached functions of a module, empty_caches! likewise -set_cache_size!(fusion, 1_000) +set_cache_size!(combine, 1_000) ``` Default sizes can be configured per package and per function through `LocalPreferences.toml`; see the [configuration docs](https://lkdvos.github.io/Cached.jl/dev/configuration/). +With [SQLite.jl](https://github.com/JuliaDatabases/SQLite.jl) loaded, results can also be kept on disk, below the RAM cache, in one database per function and node: + +```julia +using SQLite +Cached.DiskCacheStyle(::typeof(combine), a, b) = DiskCache() +disk_cache_info(combine) # its database on this node: path, entries, size +``` + +See the [disk caching docs](https://lkdvos.github.io/Cached.jl/dev/disk/). + With [TimerOutputs.jl](https://github.com/KristofferC/TimerOutputs.jl) loaded, `enable_cache_timers!(MyPackage, to)` times the lookups and computations of the cached functions owned by `MyPackage` and its submodules, and `disable_cache_timers!(MyPackage)` brings back the zero-cost default. See the [timing docs](https://lkdvos.github.io/Cached.jl/dev/timing/). -With [Tachikoma.jl](https://github.com/kahliburke/Tachikoma.jl) loaded, `cache_dashboard()` opens a live terminal dashboard to browse, empty and resize the caches; see the [dashboard docs](https://lkdvos.github.io/Cached.jl/dev/dashboard/). +With [Tachikoma.jl](https://github.com/kahliburke/Tachikoma.jl) loaded, `cache_dashboard()` opens a live terminal dashboard to browse, empty and resize the caches, with a second tab for the disk caches; see the [dashboard docs](https://lkdvos.github.io/Cached.jl/dev/dashboard/). ```text - 4 caches, 383 entries - Name ▲ Hit rate Size Kind Activity - Main.fib █████67%▋░░░░ ▏░░░░░░░61/10k░░░░░░░░ LRU ████████ -▌Main.fsymbol █████67%▊░░░░ ▋░░░░░░276/10k░░░░░░░░ Clock ▄█▆▃▇▅▂▆ - Main.label ████100%█████ ▏░░░░░░░6/10k░░░░░░░░░ Clock ▇█ ▁▃▄▅▇ - Main.matrix ████100%█████ ███▊175KiB/1.0MiB░░░░░ Clock ▆▇█▁▂▃▄▄ - ↑↓ select ⏎ resize e empty s sort r reverse / filter g refresh q quit +[RAM] │ Disk + 4 caches · 325 entries · 175KiB in byte-measured caches · recent hit rate 70.3% + Name ▲ Hit rate Size Kind Activity +▌Main.fib █████60%░░░░░ ▏░░░░░░░3/10k░░░░░░░░░ LRU+disk ███████ + Main.label ████100%█████ ▏░░░░░░░6/10k░░░░░░░░░ Clock ▇█ ▁▃▄▅▇ + Main.matrix ████100%█████ ███▊175KiB/1.0MiB░░░░░ Clock ▆▇█▁▂▃▄▄ + Main.weights █████67%▊░░░░ ▋░░░░░░276/10k░░░░░░░░ Clock ▄█▆▃▇▅▂▆ + ⇥ tab ↑↓ select ⏎ resize e empty s sort r reverse / filter g refresh q quit ``` diff --git a/benchmark/disk_stress.jl b/benchmark/disk_stress.jl new file mode 100644 index 0000000..e08684a --- /dev/null +++ b/benchmark/disk_stress.jl @@ -0,0 +1,99 @@ +# Stress test of the disk cache: several processes computing and reading the same entries of +# one function, whose RAM cache is off, so that every call goes to disk. Every value read is +# checked. Run with the test environment, which has SQLite: +# +# # on one node: NPROCS processes sharing the node's database in DIR +# julia --project=test benchmark/disk_stress.jl DIR [NPROCS = 4] [NKEYS = 200] +# +# # on several nodes: start one worker per process (e.g. with your job scheduler); each node +# # writes its own database file. Then check the files +# julia --project=test benchmark/disk_stress.jl --worker DIR NKEYS +# julia --project=test benchmark/disk_stress.jl --check DIR NKEYS +# +# `test/test_disk.jl` includes this file and calls `stress` on a temporary directory. + +using Cached +using SQLite: SQLite + +const COMPUTED = Ref(0) + +# Store the disk caches of this process in `dir`: an environment with the `disk_path` +# preference, added to the load path. +function use_disk_path(dir) + env = mktempdir() + write(joinpath(env, "Project.toml"), "[deps]\nCached = \"$(Base.PkgId(Cached).uuid)\"\n") + write(joinpath(env, "LocalPreferences.toml"), "[Cached]\ndisk_path = $(repr(dir))\n") + push!(LOAD_PATH, env) + return env +end + +# 0.5 to 4.5 KiB per value, so that some values span several database pages +stress_expected(k) = [sin(k * i) for i in 1:(64 + 37k % 512)] + +@cached stress_value(k::Int)::Vector{Float64} = (COMPUTED[] += 1; stress_expected(k)) +Cached.CacheStyle(::typeof(stress_value), ::Int) = NoCache() +Cached.DiskCacheStyle(::typeof(stress_value), ::Int) = DiskCache() + +# Every key three times, in an order that depends on `seed`. +function stress_worker(dir, nkeys, seed) + use_disk_path(dir) + order = sort(1:(3nkeys); by = i -> hash((i, seed))) + bad = 0 + for i in order + k = mod1(i, nkeys) + stress_value(k) == stress_expected(k) || (bad += 1) + end + println("RESULT host=$(gethostname()) pid=$(getpid()) calls=$(3nkeys) computed=$(COMPUTED[]) bad=$bad") + return bad +end + +# The database files in `dir`, which must each hold `nkeys` correct entries. +function stress_check(dir, nkeys) + files = filter(endswith(".sqlite"), readdir(dir)) + bad = 0 + for file in files + db = SQLite.DB(joinpath(dir, file)) + n = first(SQLite.DBInterface.execute(db, "SELECT count(*) FROM entries"))[1] + close(db) + n == nkeys || (bad += 1; println("CHECK $file has $n entries, expected $nkeys")) + end + return (; files = length(files), bad) +end + +# `nprocs` worker processes on this node, at the same time. +function stress(dir; nprocs = 4, nkeys = 200, project = Base.active_project()) + cmds = [ + `$(Base.julia_cmd()) --startup-file=no -t1 --heap-size-hint=500M --project=$project $(@__FILE__) --worker $dir $nkeys $i` + for i in 1:nprocs + ] + outs = Vector{String}(undef, nprocs) + errs = [IOBuffer() for _ in 1:nprocs] + @sync for i in 1:nprocs + @async outs[i] = read(pipeline(addenv(cmds[i], "JULIA_PKG_OFFLINE" => "true"); stderr = errs[i]), String) + end + errs = String.(take!.(errs)) + warnings = count(e -> occursin("Warning", e), errs) + warnings > 0 && print(stderr, join(errs)) + results = [match(r"computed=(\d+) bad=(\d+)", o) for o in outs] + any(isnothing, results) && error("a worker failed:\n" * join(outs, "\n")) + computed = sum(r -> parse(Int, r[1]), results) + bad = sum(r -> parse(Int, r[2]), results) + return (; computed, bad, warnings, check = stress_check(dir, nkeys)) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + if ARGS[1] == "--worker" + stress_worker(ARGS[2], parse(Int, ARGS[3]), length(ARGS) >= 4 ? parse(Int, ARGS[4]) : getpid()) + elseif ARGS[1] == "--check" + r = stress_check(ARGS[2], parse(Int, ARGS[3])) + println("CHECK files=$(r.files) bad=$(r.bad)") + else + nprocs = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 4 + nkeys = length(ARGS) >= 3 ? parse(Int, ARGS[3]) : 200 + t = @elapsed r = stress(ARGS[1]; nprocs, nkeys) + println( + "$nprocs processes, $nkeys keys: computed $(r.computed) times, $(r.bad) bad values, ", + "$(r.warnings) workers with warnings, $(r.check.files) files with $(r.check.bad) bad, in $(round(t; digits = 1)) s" + ) + end +end diff --git a/docs/Project.toml b/docs/Project.toml index 3fd8440..8ec86fa 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,11 +1,13 @@ [deps] Cached = "1b238080-9255-4fe9-b224-89eb24efe93b" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" [compat] Documenter = "1" +SQLite = "1.6" Tachikoma = "2.7" TimerOutputs = "0.5, 1" diff --git a/docs/make.jl b/docs/make.jl index dc1570e..d762b66 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,7 +14,8 @@ makedocs(; ), pages = [ "Home" => "index.md", "Configuration" => "configuration.md", "Dashboard" => "dashboard.md", - "Timing" => "timing.md", "Implementing a cache" => "interface.md", "Reference" => "reference.md", + "Timing" => "timing.md", "Disk caching" => "disk.md", "Implementing a cache" => "interface.md", + "Reference" => "reference.md", ], ) diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 5b3f5b6..0e06bc2 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -14,6 +14,8 @@ from `LocalPreferences.toml`, next to the active project. | `maxsize` | integer ≥ 0 | `10000` | limit of a function's cache, all signatures together, in entries or bytes (see `measure`) | | `measure` | `"count"` or `"bytes"` | `"count"` | count entries, or measure values with [`Cached.cachesize`](@ref) | | `container` | `"ClockCache"` or `"LRU"` | `"ClockCache"` | container of the default `CacheStyle`; only in the `[Cached]` section | +| `disk` | `true` or `false` | `true` | whether the [disk cache](disk.md) of a function with a `DiskCacheStyle` is used | +| `disk_path` | a directory | `""` (a scratch space) | where the [disk caches](disk.md) are stored | ## Where settings come from @@ -26,7 +28,7 @@ Settings are looked up per function, in this order (first match wins): 5. the built-in defaults above. The package that owns a function is the package of the module that defines it, `parentmodule(typeof(f))`. -For a function extended by several packages (for example `TensorKitSectors.Fsymbol`), that is the package that defines the function, not the ones that add cached methods to it. +For a function extended by several packages, that is the package that defines the function, not the ones that add cached methods to it. ```toml # LocalPreferences.toml @@ -34,10 +36,10 @@ For a function extended by several packages (for example `TensorKitSectors.Fsymb maxsize = 10000 container = "LRU" -[TensorKit.Cached] +[MyPackage.Cached] maxsize = 50000 -[TensorKit.Cached.fsbraid] +[MyPackage.Cached.expensive] measure = "bytes" maxsize = 2_000_000_000 ``` @@ -46,6 +48,7 @@ Unknown keys and invalid values are ignored with a warning. ## When changes take effect +- `disk` and `disk_path` are read when a function first uses its disk cache in the session. - `maxsize` and `measure` are read when a function's first cache is created, so a change applies to functions that have not been called yet in the current session, and to every function after a restart. - `container` is a compile-time preference, because it selects the default `CacheStyle`. Changing it recompiles Cached on the next start. `GlobalCache()` and `TaskLocalCache()` use this container, so `CacheStyle` methods that return them follow the preference. @@ -54,9 +57,9 @@ Use [`set_cache_preferences!`](@ref) to write the sections, which merges with wh ```julia using Cached set_cache_preferences!(; maxsize = 50_000) # [Cached] -set_cache_preferences!(TensorKit; measure = "bytes") # [TensorKit.Cached] -set_cache_preferences!(TensorKit.fsbraid; maxsize = 1000) # [TensorKit.Cached.fsbraid] -set_cache_preferences!(TensorKit.fsbraid; maxsize = nothing) # remove a setting +set_cache_preferences!(MyPackage; measure = "bytes") # [MyPackage.Cached] +set_cache_preferences!(MyPackage.expensive; maxsize = 1000) # [MyPackage.Cached.expensive] +set_cache_preferences!(MyPackage.expensive; maxsize = nothing) # remove a setting ``` ## Measuring sizes diff --git a/docs/src/dashboard.md b/docs/src/dashboard.md index 28713ef..46c62a5 100644 --- a/docs/src/dashboard.md +++ b/docs/src/dashboard.md @@ -14,61 +14,103 @@ cache_dashboard() # refreshes every second cache_dashboard(; interval = 0.2, filter = "fs") # faster, only functions whose name contains "fs" ``` -With a few example caches it looks like this (rendered while building these docs): +It has two tabs, for the caches in RAM and on disk. With a few example caches, the RAM tab +looks like this (rendered while building these docs): ```@example dashboard -using Cached, Tachikoma # hide +using Cached, Tachikoma, SQLite # hide +env = mktempdir() # the disk caches go to a temporary directory # hide +write(joinpath(env, "Project.toml"), "[deps]\nCached = \"$(Base.PkgId(Cached).uuid)\"\n") # hide +write(joinpath(env, "LocalPreferences.toml"), "[Cached]\ndisk_path = $(repr(mktempdir()))\n") # hide +push!(LOAD_PATH, env) # hide Core.eval(Main, :(using Cached)) # hide Core.eval(Main, quote # hide @cached fib(n::Int)::BigInt = n < 2 ? BigInt(n) : fib(n - 1) + fib(n - 2) # hide - @cached fsymbol(a, b, c) = a + b * c # hide + @cached weights(a, b, c) = a + b * c # hide @cached matrix(n::Int)::Matrix{Float64} = zeros(n, n) # hide @cached label(s::Symbol; upper::Bool = false)::String = upper ? uppercase(string(s)) : string(s) # hide + @cached kernel(j::Int)::Matrix{Float64} = ones(j, j) # hide Cached.CacheStyle(::typeof(fib), ::Int) = GlobalLRUCache() # hide + Cached.DiskCacheStyle(::typeof(fib), ::Int) = DiskCache() # hide + Cached.CacheStyle(::typeof(kernel), ::Int) = NoCache() # hide + Cached.DiskCacheStyle(::typeof(kernel), ::Int) = DiskCache() # hide set_cache_size!(matrix, 2^20; by = Cached.cachesize) # hide end) # hide const Ext = Base.get_extension(Cached, :CachedTachikomaExt) # hide m = Ext.Dashboard(; interval = Inf) # hide for step in 1:40 # hide for i in 1:(20 + (37step) % 60) # hide - Main.fsymbol(1 + i % 6, 1 + (7i + step) % 6, 1 + (13i) % 6) # hide + Main.weights(1 + i % 6, 1 + (7i + step) % 6, 1 + (13i) % 6) # hide end # hide - step > 34 && foreach(i -> Main.fsymbol(1000step + i, 1, 1), 1:40) # a burst of new keys # hide + step > 34 && foreach(i -> Main.weights(1000step + i, 1, 1), 1:40) # a burst of new keys # hide + step % 10 == 0 && empty_caches!(Main.fib) # then read back from disk # hide Main.fib(step + 20) # hide foreach(i -> Main.matrix(1 + (11i + 3step) % 40), 1:(1 + step % 9)) # hide foreach(i -> Main.label((:a, :b, :c)[1 + i % 3]; upper = isodd(step)), 1:(step % 7)) # hide + foreach(i -> Main.kernel(1 + (5i + step) % 24), 1:(2 + step % 5)) # hide Ext.refresh!(m, Float64(step)) # hide end # hide -Tachikoma.update!(m, Tachikoma.KeyEvent(:down)) # hide -tb = Tachikoma.TestBackend(100, 16) # hide -frame = Tachikoma.Frame(tb.buf, Tachikoma.Rect(1, 1, 100, 16), Tachikoma.GraphicsRegion[], Tachikoma.PixelSnapshot[]) # hide -Tachikoma.view(m, frame) # hide -foreach(y -> println(rstrip(Tachikoma.row_text(tb, y))), 1:16) # hide +foreach(t -> filter!(r -> !occursin("doctest", r.label), t.rows), m.tabs) # functions of the doctests of other pages # hide +Ext.rebuild!(m) # hide +function preview(m, height) # hide + tb = Tachikoma.TestBackend(100, height) # hide + Tachikoma.view(m, Tachikoma.Frame(tb.buf, Tachikoma.Rect(1, 1, 100, height), Tachikoma.GraphicsRegion[], Tachikoma.PixelSnapshot[])) # hide + return foreach(y -> println(rstrip(Tachikoma.row_text(tb, y))), 1:height) # hide +end # hide +preview(m, 16) # hide ``` -Each function is a row (or one per container type, in the rare case that its -[`CacheStyle`](@ref) selects several). The columns are a bar for the recent hit rate (over the -last 10 refreshes, `-` without lookups), a bar for the size against the limit (in bytes for -caches measured in bytes), the container (`LRU` or `Clock`), and a sparkline of hits per -second. The name column is as wide as the longest name; narrow terminals cut names in the middle -and drop columns from the right, and below 40×8 the dashboard asks for a larger terminal. The -panel at the bottom shows the numbers for the selected row, including its lifetime hit rate. -Task-local caches are not shown. +The header sums all caches: their entries, the bytes of those measured in bytes, and the recent +hit rate (over the last 10 refreshes). Each function is a row (or one per container type, in +the rare case that its [`CacheStyle`](@ref) selects several). The columns are a bar for the +recent hit rate (`-` without lookups), a bar for the size against the limit (in bytes for +caches measured in bytes), the container (`LRU` or `Clock`, with `+disk` if the function also +has a [disk cache](disk.md)), and a sparkline of hits per second. The panel at the bottom shows +the numbers for the selected row, including its lifetime hit rate. Task-local caches are not +shown. + +The Disk tab (`Tab`, or `2`) lists the functions whose [disk cache](disk.md) is open in this +process (with SQLite loaded), including those without a cache in RAM: + +```@example dashboard +m.tab = 2 # hide +wait(Ext.read_disk!(m).disktask) # the entries and sizes on disk # hide +preview(m, 8) # hide +Base.get_extension(Cached, :CachedSQLiteExt).close_all() # hide +filter!(!=(env), LOAD_PATH) # hide +nothing # hide +``` + +Its columns are the entries and size of each database on this node (the size includes the +write-ahead log), the hit rate of the lookups on disk, and their activity; the panel adds the +path of the database. Hits are results read from disk and misses results computed and written, +counted in this process (see [`Cached.disk_cache_stats`](@ref)); the header sums them over all +disk caches, with the total entries and size, and says when disk caches are off +([`disable_disk_caches!`](@ref)). The entries and sizes come from [`disk_cache_info`](@ref), +which is read in the background while the tab is shown, at most every 10 seconds (and on `g`), +as counting the entries of a large database on a network file system can take a while; `…` +shows until they arrive. + +The name column is as wide as the longest name; narrow terminals cut names in the middle and +drop columns from the right, and below 40×8 the dashboard asks for a larger terminal. | Key | Action | |:-|:-| +| `Tab`, `1` / `2` | switch to the next tab / the RAM or Disk tab | | `↑` `↓`, `PgUp` `PgDn`, `Home` `End` | select a row | -| `Enter` | resize the selected cache (see below) | -| `e` | empty the selected cache | -| `s` / `r` | sort by the next column / reverse the order | -| `/` | filter by function name (`Enter` applies, `Esc` cancels) | +| `Enter` | resize the selected cache, in RAM (see below) | +| `e` | empty the selected cache, in RAM | +| `s` / `r` | sort by the next column / reverse the order, per tab | +| `/` | filter by function name, on both tabs (`Enter` applies, `Esc` cancels) | | `g` | refresh now | | `q`, `Esc` | quit | In resize mode, `←` and `→` halve and double the limit, `[` and `]` change it by 10%, `Enter` applies and `Esc` cancels; the size bar previews the new limit. Applying calls [`set_cache_size!`](@ref) with the function's current measure (entries or bytes), so the limit -persists for the function. +persists for the function. Emptying and resizing act on the RAM tab only: a disk cache is +persistent and may be shared with other processes, so it is emptied with +[`empty_disk_caches!`](@ref) outside the dashboard. The statistics are read with [`cache_info`](@ref) and [`Cached.cache_stats`](@ref), which take each cache's lock only briefly, so the dashboard is safe to run while other tasks use the caches. diff --git a/docs/src/disk.md b/docs/src/disk.md new file mode 100644 index 0000000..86a9bbb --- /dev/null +++ b/docs/src/disk.md @@ -0,0 +1,163 @@ +# Disk caching + +```@meta +CurrentModule = Cached +``` + +Results that are expensive to compute can also be kept on disk, so that they survive the +process. The disk is a second level below the cache in memory: a call looks in RAM first (as +chosen by [`CacheStyle`](@ref)), then on disk, and only computes when both miss, after which the +result is written to both. + +Disk caching is a package extension on [SQLite.jl](https://github.com/JuliaDatabases/SQLite.jl): +load it with `using SQLite`, or, in a package, add SQLite to its dependencies and `import SQLite`. + +```julia +using Cached, SQLite + +@cached function expensive(n::Int)::Matrix{Float64} + # takes long to compute +end + +Cached.DiskCacheStyle(::typeof(expensive), args...) = DiskCache() +``` + +## Choosing what goes to disk + +[`DiskCacheStyle`](@ref)`(f, args...)` selects the disk strategy per function and argument +types, independently of `CacheStyle`: [`NoCache()`](@ref NoCache) (the default) or a +[`DiskCache`](@ref). Both resolve at compile time, so functions without a disk cache are not +affected, and a RAM hit costs the same with or without one. + +| `CacheStyle` | `DiskCacheStyle` | a call | +|:-|:-|:-| +| `GlobalCache()` (default) | `NoCache()` (default) | RAM, else compute | +| `GlobalCache()` | `DiskCache()` | RAM, else disk, else compute | +| `NoCache()` | `DiskCache()` | disk, else compute, on every call | + +## Where the data goes + +Each function has one SQLite database per machine, `.-v-.sqlite`. The +directory is the `disk_path` preference of the function, its package, or Cached (see +[Configuration](configuration.md)), and by default a +[scratch space](https://github.com/JuliaPackaging/Scratch.jl) of the package that owns `f`. +Functions outside packages (in scripts or the REPL) use a scratch space of Cached. + +```julia +set_cache_preferences!(MyPackage; disk_path = "/path/to/cache") +``` + +- **One file per function and machine**, whatever the number of entries, which matters on file + systems that limit the number of files. +- **Processes on one machine share the database**, reading and writing at the same time. A + write is a transaction, so an interrupted process never leaves a partial entry. +- **Machines write separate files**, named after the host, since SQLite's locking does not work + across machines on a shared file system. Results are not shared between machines. + +Every disk lookup takes SQLite's file locks. On a local disk a lookup takes microseconds; on a +network file system the locks go over the network, which can make it orders of magnitude +slower. That is negligible for results that take long to compute, which are the ones worth +keeping on disk; otherwise, point `disk_path` at a local disk. + +## Versions + +The file name holds a version, [`Cached.diskversion(f)`](@ref), `"1"` by default. Keeping it +current is up to you: bump it when the results of `f` change, and the old file is no longer +read. + +```julia +Cached.diskversion(::typeof(expensive)) = "2" +``` + +The default format is that of `Serialization`, which is not guaranteed to be readable by other +Julia versions, nor after the definition of a stored type changes. Bump the version when that +happens, or write a stable format with a serializer of your own (below). + +Entries that cannot be read, or are not of the value type of the call, count as misses and are +overwritten. Errors of the disk itself, such as a full disk, never fail a call: they are +reported once, and the call goes on without the disk. + +## Formats + +Keys and values are written with `Serialization`, indexed by the SHA-256 hash of the serialized +key; the key is stored too, to guard against hash collisions. + +The serializer is a parameter of the style, `DiskCache(; serializer = Serializer)`. To choose +the format of some types, define a serializer type with its own `serialize` and `deserialize` +methods for them; everything else is written as by `Serialization`. A serializer is a mutable +`AbstractSerializer` with these fields and a constructor from an `IO`: + +```julia +using Serialization +using Serialization: AbstractSerializer + +mutable struct MySerializer{I <: IO} <: AbstractSerializer + io::I + counter::Int + table::IdDict{Any, Any} + pending_refs::Vector{Int} + version::Int + MySerializer(io::I) where {I <: IO} = new{I}(io, 0, IdDict(), Int[], 0) +end + +function Serialization.serialize(s::MySerializer, x::MyType) + Serialization.writetag(s.io, Serialization.OBJECT_TAG) + serialize(s, typeof(x)) + # write `x` to `s.io` in your own format +end +function Serialization.deserialize(s::MySerializer, ::Type{T}) where {T <: MyType} + # read it back +end + +Cached.DiskCacheStyle(::typeof(expensive), args...) = DiskCache(; serializer = MySerializer) +``` + +`writetag` and `OBJECT_TAG` are internals of `Serialization`, used the same way by +`Distributed`'s `ClusterSerializer`. + +## Turning it off + +[`disable_disk_caches!()`](@ref disable_disk_caches!) turns all disk caches off in this process, +and [`enable_disk_caches!()`](@ref enable_disk_caches!) back on; while off, nothing is read from +or written to disk. The `disk = false` preference turns them off per function, package, or +globally: + +```julia +set_cache_preferences!(MyPackage; disk = false) +``` + +`disk` and `disk_path` are read when a function first uses its disk cache in a session. Disk +caches are never used during precompilation. + +## Managing disk caches + +```julia +disk_cache_info(expensive) # [expensive => (; path, entries, bytes)] +disk_cache_info(MyPackage) # ... of its functions used in this process +empty_disk_caches!(expensive) # remove the entries +Cached.disk_cache_stats() # [expensive => (; hits, misses), ...] in this process +``` + +[`disk_cache_info`](@ref) and [`empty_disk_caches!`](@ref) act on this machine's current +database; files of older versions or other machines are left alone. The Disk tab of the +[dashboard](dashboard.md) lists the open disk caches with these numbers. + +## Precomputed results as an artifact + +A package can ship precomputed results, read-only, as a +[Pkg artifact](https://pkgdocs.julialang.org/v1/artifacts/): fill the disk cache, export it with +[`export_disk_cache`](@ref), and point [`Cached.disk_artifact`](@ref) at the artifact. + +```julia +using Pkg.Artifacts +hash = create_artifact() do dir + foreach(expensive, 1:100) # fill the disk cache + export_disk_cache(expensive, dir) # writes .expensive-v.sqlite +end +# archive, upload and bind it in Artifacts.toml as usual; then, in the package: +Cached.disk_artifact(::typeof(expensive)) = artifact"expensive" +``` + +A call then looks in RAM, the artifact, and this machine's database, in that order, and only +then computes. New results go to the database; the artifact is never written, and holds the +results of one version of `f`. diff --git a/docs/src/index.md b/docs/src/index.md index f3861e7..3488c00 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -12,20 +12,20 @@ Transparent, strategy-aware memoization for Julia functions. ```julia using Cached -@cached function fusion(a, b; normalize = true) +@cached function combine(a, b; normalize = true) # expensive computation end -fusion(1, 2) # computed -fusion(1, 2) # looked up in fusion's cache; the result is still inferred -uncached(fusion, 1, 2) # bypasses the cache +combine(1, 2) # computed +combine(1, 2) # looked up in combine's cache; the result is still inferred +uncached(combine, 1, 2) # bypasses the cache # choose the strategy per function and argument type -Cached.CacheStyle(::typeof(fusion), a::Int, b::Int) = TaskLocalCache{LRU}() +Cached.CacheStyle(::typeof(combine), a::Int, b::Int) = TaskLocalCache{LRU}() -cache_info(fusion) # hit/miss statistics +cache_info(combine) # hit/miss statistics cache_info(MyPackage) # ... of all cached functions of a module, empty_caches! likewise -set_cache_size!(fusion, 1_000) +set_cache_size!(combine, 1_000) ``` - [`@cached`](@ref) works on any method definition: positional, default, varargs and keyword @@ -37,6 +37,8 @@ set_cache_size!(fusion, 1_000) [`NoCache`](@ref). - Default sizes are configured per package and per function through preferences; see [Configuration](configuration.md). +- With SQLite.jl loaded, [`DiskCacheStyle`](@ref) keeps results on disk as well, below the + RAM cache, in one database per function and node; see [Disk caching](disk.md). - With Tachikoma.jl loaded, [`cache_dashboard`](@ref) opens a live terminal dashboard; see [Dashboard](dashboard.md). - With TimerOutputs.jl loaded, [`enable_cache_timers!`](@ref) times lookups and computations diff --git a/docs/src/reference.md b/docs/src/reference.md index 8f501a8..a573282 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -43,6 +43,21 @@ Cached.cachesize cache_dashboard ``` +## Disk caching + +```@docs +DiskCacheStyle +DiskCache +Cached.diskversion +Cached.disk_artifact +disk_cache_info +Cached.disk_cache_stats +empty_disk_caches! +export_disk_cache +disable_disk_caches! +enable_disk_caches! +``` + ## Hooks and timing ```@docs diff --git a/docs/src/timing.md b/docs/src/timing.md index 06f0d5a..4cc201e 100644 --- a/docs/src/timing.md +++ b/docs/src/timing.md @@ -7,18 +7,19 @@ CurrentModule = Cached With [TimerOutputs.jl](https://github.com/KristofferC/TimerOutputs.jl) loaded, [`enable_cache_timers!`](@ref) records the time spent in the cached functions of a package, in two nested sections per function: - `lookup f` wraps the whole cache lookup, hit or miss; -- `compute f` wraps the call of the implementation: nested in `lookup f` on a miss, and on its own under [`NoCache`](@ref). +- `compute f` wraps the call of the implementation: nested in `lookup f` on a miss, and on its own under [`NoCache`](@ref); +- `disk f`, for functions with a [disk cache](disk.md), wraps the disk lookup after a RAM miss, with `compute f` nested in it when the disk misses too. ```@example timer using Cached, TimerOutputs -@cached fsymbol(a, b, c) = a + b * c +@cached weights(a, b, c) = a + b * c @cached matrix(n::Int)::Matrix{Float64} = zeros(n, n) to = TimerOutput() enable_cache_timers!(@__MODULE__, to) # the timer defaults to TimerOutputs' global one for i in 1:200 - fsymbol(1 + i % 6, 1 + (7i) % 6, 1 + (13i) % 6) + weights(1 + i % 6, 1 + (7i) % 6, 1 + (13i) % 6) matrix(1 + i % 10) end disable_cache_timers!(@__MODULE__) @@ -32,17 +33,18 @@ Enabling a package again replaces its timer. Do not enable timers during precompilation. What counts is the package that owns the function (including its submodules), not where `@cached` was written; modules outside packages, such as in the REPL, count separately. -For instance, TensorKit times its own cached functions and those of TensorKitSectors, including the `@cached` methods of `TensorKitSectors.Fsymbol` defined in SUNRepresentations: +For instance, if `MyPackage` adds `@cached` methods to a function `OtherPackage.f`, then `enable_cache_timers!(OtherPackage)` times them, and `enable_cache_timers!(MyPackage)` does not. +A package that wants all of its cached functions timed enables the packages that own them: ```julia -function TensorKit.enable_timers!() - enable_cache_timers!(TensorKit, GLOBAL_TIMER) - enable_cache_timers!(TensorKitSectors, GLOBAL_TIMER) +function MyPackage.enable_timers!() + enable_cache_timers!(MyPackage, TIMER) + enable_cache_timers!(OtherPackage, TIMER) end ``` The labels come from [`Cached.instrument_label`](@ref), `"lookup f"` and `"compute f"` by default; overload it to pick your own: ```julia -Cached.instrument_label(::typeof(fsbraid), ::Val{:lookup}) = "bookkeeping: cache fsbraid" +Cached.instrument_label(::typeof(expensive), ::Val{:lookup}) = "cache: expensive" ``` diff --git a/ext/CachedSQLiteExt.jl b/ext/CachedSQLiteExt.jl new file mode 100644 index 0000000..5005b60 --- /dev/null +++ b/ext/CachedSQLiteExt.jl @@ -0,0 +1,206 @@ +module CachedSQLiteExt + +# The disk caches of `DiskCache` (see `docs/src/disk.md`): per function, an optional read-only +# artifact and one SQLite database per node, in WAL mode, shared by the processes of the node. +# Nodes write separate files, as WAL needs shared memory, which network file systems do not +# give across nodes. Disk errors never fail a call: they warn once and the call goes on. + +using Cached: Cached, DiskCache, _compute, _fname, _owner, _ispackage, _within, instrument, + _resolve_settings, _cached_section, _package_section, get_scratch!, sha256 +using SQLite: SQLite, DBInterface +using Serialization: serialize, deserialize, writeheader + +const BUSY_TIMEOUT = 60_000 # ms that a write waits for another process holding the write lock + +struct Store + db::SQLite.DB + get::SQLite.Stmt + put::SQLite.Stmt + lock::ReentrantLock # statements are not safe to use from several threads at once +end + +function Store(path; immutable = false) + if immutable # an artifact: read-only, and no locks + db = SQLite.DB(string("file:", replace(path, '%' => "%25", '?' => "%3f", '#' => "%23"), "?mode=ro&immutable=1")) + else + mkpath(dirname(path)) + db = SQLite.DB(path) + end + try + SQLite.busy_timeout(db, BUSY_TIMEOUT) + # Processes creating a database at once can find it locked without the busy timeout + # applying (switching to WAL needs an exclusive lock), so retry. + locked(_, e) = e isa SQLite.SQLiteException && occursin("locked", e.msg) + immutable || retry(() -> setup(db); delays = (0.01 + 0.05rand() for _ in 1:50), check = locked)() + get = SQLite.Stmt(db, "SELECT value FROM entries WHERE keyhash = ?1 AND key = ?2") + put = SQLite.Stmt(db, "INSERT OR REPLACE INTO entries VALUES (?1, ?2, ?3)") + return Store(db, get, put, ReentrantLock()) + catch + close(db) + rethrow() + end +end + +# WAL mode is persistent, so only a new database is switched to it. +function setup(db) + scalar(db, "PRAGMA journal_mode") == "wal" || SQLite.execute(db, "PRAGMA journal_mode = WAL") + SQLite.execute(db, "CREATE TABLE IF NOT EXISTS entries (keyhash BLOB PRIMARY KEY, key BLOB NOT NULL, value BLOB NOT NULL)") + SQLite.execute(db, "PRAGMA synchronous = NORMAL") # durable across crashes of the process + return nothing +end + +# The first column of the first row of `sql`, finalized right away to end its read transaction. +function scalar(db, sql) + stmt = SQLite.Stmt(db, sql; register = false) + try + return first(DBInterface.execute(stmt, ()))[1] + finally + DBInterface.close!(stmt) + end +end + +function load(s::Store, h, kb) + return @lock s.lock begin + q = DBInterface.execute(s.get, (h, kb)) + try + row = iterate(q) + row === nothing ? nothing : first(row)[1] + finally + DBInterface.close!(q) # ends the read transaction + end + end +end + +# ---- the stores of a function, opened on first use ---- + +struct Stores + f::Any + artifact::Union{Nothing, Store} + node::Union{Nothing, Store} + hits::Threads.Atomic{Int} # lookups in this process, for `disk_cache_stats` + misses::Threads.Atomic{Int} +end +Stores(f, artifact, node) = Stores(f, artifact, node, Threads.Atomic{Int}(0), Threads.Atomic{Int}(0)) + +const STORES = IdDict{Any, Stores}() # typeof(f) => its stores +const LOCK = ReentrantLock() + +_sanitize(s) = replace(string(s), r"[^A-Za-z0-9_.]" => c -> join("%" * string(b; base = 16, pad = 2) for b in codeunits(c))) + +# `.-v`: the file name of an artifact, followed by `-` on a node. +storename(f) = string(_sanitize(join((fullname(parentmodule(typeof(f)))..., _fname(f)), '.')), "-v", _sanitize(Cached.diskversion(f))) + +# Without the `disk_path` preference: a scratch space of the package owning `f`, or of Cached. +diskdir(f, path) = !isempty(path) ? path : _ispackage(_owner(f)) ? get_scratch!(_owner(f), "Cached") : + get_scratch!(Cached, string(nameof(_owner(f)))) + +function stores(f::F) where {F} + s = @lock LOCK get(STORES, F, nothing) + s === nothing || return s + new = open_stores(f) # outside the lock: opening may wait for the busy timeout + s = @lock LOCK get!(STORES, F, new) + s === new || close_stores(new) + return s +end + +function open_stores(f) + settings = _resolve_settings(_fname(f), _cached_section(), _package_section(f)) + settings.disk || return Stores(f, nothing, nothing) + dir = Cached.disk_artifact(f) + artifact = dir === nothing ? nothing : attempt(() -> Store(joinpath(dir, storename(f) * ".sqlite"); immutable = true), "open the disk artifact of `$(_fname(f))` in $dir") + path = joinpath(diskdir(f, settings.disk_path), string(storename(f), '-', _sanitize(gethostname()), ".sqlite")) + return Stores(f, artifact, attempt(() -> Store(path), "open the disk cache of `$(_fname(f))` at $path")) +end + +close_stores(s::Stores) = foreach(x -> x === nothing || close(x.db), (s.artifact, s.node)) +close_all() = @lock LOCK (foreach(close_stores, values(STORES)); empty!(STORES); nothing) + +__init__() = atexit(close_all) # merges the write-ahead logs into the databases + +# `f()`, or `nothing` if it throws, with a warning (once per message) unless `msg === nothing`. +function attempt(f, msg) + return try + f() + catch e + e isa InterruptException && rethrow() + msg === nothing || @warn "Cached: cannot $msg; continuing without it" exception = e maxlog = 1 _id = Symbol(msg) + nothing + end +end + +function tobytes(::Type{S}, x) where {S} + io = IOBuffer() + s = S(io) + writeheader(s) + serialize(s, x) + return take!(io) +end + +# ---- the lookup ---- + +function Cached.disk_lookup(f::F, ::DiskCache{S}, ::Type{V}, key, args, kw, o) where {F, S, V} + s = stores(f) + s.artifact === nothing && s.node === nothing && return _compute(f, o, args, kw) + return instrument(f, Val(:disk), () -> lookup(f, s, S, V, key, args, kw, o), o) +end + +function lookup(f::F, s::Stores, ::Type{S}, ::Type{V}, key, args, kw, o) where {F, S, V} + # callable objects with fields share the store of their type, so they are part of the key + kb = attempt(() -> tobytes(S, Base.issingletontype(F) ? key : (f, key)), "serialize a key of `$(_fname(f))`") + kb === nothing && (Threads.atomic_add!(s.misses, 1); return _compute(f, o, args, kw)) + h = sha256(kb) + for store in (s.artifact, s.node) + store === nothing && continue + bytes = attempt(() -> load(store, h, kb), "read the disk cache at $(store.db.file)") + bytes === nothing && continue + # an entry that cannot be read, or of another type, is a miss and is overwritten + v = attempt(() -> Some(deserialize(S(IOBuffer(bytes)))), nothing) + v !== nothing && something(v) isa V && (Threads.atomic_add!(s.hits, 1); return something(v)::V) + end + Threads.atomic_add!(s.misses, 1) + v = _compute(f, o, args, kw) + node = s.node + node === nothing || attempt("write `$(_fname(f))` to the disk cache at $(node.db.file)") do + vb = tobytes(S, v) + @lock node.lock SQLite.execute(node.put, (h, kb, vb)) # one statement is one transaction + end + return v +end + +# ---- management ---- + +selector(f) = (stores(f); s -> s.f isa typeof(f)) +selector(m::Module) = s -> _within(parentmodule(typeof(s.f)), m) +selected(x) = (select = selector(x); [s for s in @lock(LOCK, collect(values(STORES))) if s.node !== nothing && select(s)]) + +function Cached.disk_cache_info(x) + return map(selected(x)) do s + path = s.node.db.file + entries = @lock s.node.lock scalar(s.node.db, "SELECT count(*) FROM entries")::Int + s.f => (; path, entries, bytes = filesize(path) + filesize(path * "-wal")) + end +end + +Cached.disk_cache_stats() = Pair{Any, @NamedTuple{hits::Int, misses::Int}}[ + s.f => (; hits = s.hits[], misses = s.misses[]) for s in @lock(LOCK, collect(values(STORES))) if s.artifact !== nothing || s.node !== nothing +] + +Cached.empty_disk_caches!(x) = foreach(s -> @lock(s.node.lock, SQLite.execute(s.node.db, "DELETE FROM entries")), selected(x)) + +function Cached.export_disk_cache(f, dir::AbstractString) + node = stores(f).node + node === nothing && throw(ArgumentError("disk caching is disabled for `$(_fname(f))`")) + path = joinpath(dir, storename(f) * ".sqlite") + ispath(path) && throw(ArgumentError("$path exists")) + mkpath(dir) + @lock node.lock SQLite.execute(node.db, "VACUUM INTO ?1", (path,)) # compact and consistent + db = SQLite.DB(path) + try + SQLite.execute(db, "PRAGMA journal_mode = DELETE") # readable from a read-only directory + finally + close(db) + end + return path +end + +end # module CachedSQLiteExt diff --git a/ext/CachedTachikomaExt.jl b/ext/CachedTachikomaExt.jl index a622382..d706024 100644 --- a/ext/CachedTachikomaExt.jl +++ b/ext/CachedTachikomaExt.jl @@ -2,19 +2,23 @@ module CachedTachikomaExt # Terminal dashboard of the global caches, see `Cached.cache_dashboard`. # -# The model keeps its own copy of the statistics, re-read from `cache_info` and `cache_stats` -# every `interval` seconds; rendering only reads that copy, so a frame never touches a cache. -# Actions (empty, resize) call the locked public API and refresh immediately. +# Two tabs: the caches in RAM, and the disk caches (SQLite extension). The model keeps its own +# copy of the statistics, re-read from `cache_info`, `cache_stats` and `disk_cache_stats` (which +# reads no files) every `interval` seconds; rendering only reads that copy, so a frame never +# touches a cache. The entries and size on disk come from `disk_cache_info`, read for all disk +# caches on a background task while the Disk tab is shown. Actions (empty, resize) call the +# locked public API and refresh immediately; they act on RAM only. using Cached: Cached, AbstractCache, cache_info, cache_stats, set_cache_size! using Tachikoma: Tachikoma, Model, Frame, KeyEvent, Rect, Layout, Vertical, Fixed, Fill, split_layout, render, set_string!, set_char!, tstyle, center, right, Block, StatusBar, Span, - Sparkline, Gauge, Paragraph, TextInput, handle_key!, text, app + Sparkline, Gauge, Paragraph, TextInput, TabBar, handle_key!, text, app const STATS = @NamedTuple{hits::Int, misses::Int, length::Int, currentsize::Int, maxsize::Int} const HISTORY = 120 # refreshes kept for the sparklines const WINDOW = 10 # refreshes over which the recent hit rate is computed const MINSIZE = (40, 8) # columns and rows below which only a message is shown +const DISKINTERVAL = 10.0 # seconds between reads of the entries and size on disk # Hits and misses between successive refreshes, and hits per second. mutable struct Trend @@ -24,18 +28,22 @@ mutable struct Trend rates::Vector{Float64} end -# Hit rate over the last `WINDOW` refreshes, from the summed deltas so that quiet intervals -# do not weigh in; `NaN` without lookups. -function recent(t::Trend) - window = @view t.deltas[max(end - WINDOW + 1, 1):end] - h, m = sum(first, window; init = 0), sum(last, window; init = 0) - return h + m == 0 ? NaN : h / (h + m) +hitrate(s) = (n = s.hits + s.misses; n == 0 ? NaN : s.hits / n) + +# Hits and misses over the last `WINDOW` refreshes, summed so that quiet intervals do not +# weigh in on the recent hit rate (`NaN` without lookups). +function windowed(t::Trend) + w = @view t.deltas[max(end - WINDOW + 1, 1):end] + return (; hits = sum(first, w; init = 0), misses = sum(last, w; init = 0)) end +recent(t::Trend) = hitrate(windowed(t)) +recent(rows::Vector) = (ws = map(r -> windowed(r.trend), rows); hitrate((; hits = sum(w -> w.hits, ws; init = 0), misses = sum(w -> w.misses, ws; init = 0)))) -# One global cache, as seen at the last refresh. +# A global cache (RAM tab), or the disk cache of a function (Disk tab, without `cache`, with +# the lookups on disk in `stats`), as seen at the last refresh. struct Row f::Any - cache::AbstractCache + cache::Union{Nothing, AbstractCache} label::String # the function, as printed kind::String bytes::Bool @@ -43,32 +51,56 @@ struct Row trend::Trend end -hitrate(s) = (n = s.hits + s.misses; n == 0 ? NaN : s.hits / n) activity(r::Row) = isempty(r.trend.rates) ? 0.0 : r.trend.rates[end] fill_fraction(s, maxsize = s.maxsize) = maxsize == 0 ? 1.0 : s.currentsize / maxsize - -# Sort orders, by key on a `Row`; the last three are also the optional table columns. -const SORTS = ( - "Name" => r -> r.label, - "Hit rate" => r -> (h = recent(r.trend); isnan(h) ? -1.0 : h), - "Size" => r -> fill_fraction(r.stats), - "Activity" => activity, +id(r::Row) = r.cache === nothing ? r.f : r.cache + +# Columns after the name, dropped from the right as the terminal narrows: a header, a width, a +# function drawing the cell into a rect, and a sort key (or `nothing`). +column(name, width, draw, by = nothing) = (; name, width, draw, by) +const NAME = column("Name", 0, nothing, (m, r) -> r.label) +const HITRATE = column("Hit rate", 13, (rect, buf, r, m) -> render(hitrate_gauge(r), rect, buf), (m, r) -> (h = recent(r.trend); isnan(h) ? -1.0 : h)) +const ACTIVITY = column("Activity", 8, (rect, buf, r, m) -> render(Sparkline(r.trend.rates; style = tstyle(:accent)), rect, buf), (m, r) -> activity(r)) +const RAMCOLUMNS = ( + HITRATE, + column("Size", 22, (rect, buf, r, m) -> render(size_gauge(r, m), rect, buf), (m, r) -> fill_fraction(r.stats)), + column("Kind", 10, (rect, buf, r, m) -> set_string!(buf, rect.x, rect.y, r.kind, tstyle(:text_dim))), + ACTIVITY, +) +const DISKCOLUMNS = ( # entries and size first, as they matter most and fit in 40 columns + column("Entries", 8, (rect, buf, r, m) -> disk_cell(m, r, i -> _short(i.entries, false), rect, buf), (m, r) -> disk_key(m, r, :entries)), + column("Size", 8, (rect, buf, r, m) -> disk_cell(m, r, i -> _short(i.bytes, true), rect, buf), (m, r) -> disk_key(m, r, :bytes)), + HITRATE, + ACTIVITY, ) -@kwdef mutable struct Dashboard <: Model - interval::Float64 = 1.0 - filter::String = "" - rows::Vector{Row} = Row[] # every global cache, as read at the last refresh - lines::Vector{Row} = Row[] # filtered and sorted, as shown - trends::IdDict{AbstractCache, Trend} = IdDict{AbstractCache, Trend}() +# The rows of a tab, filtered and sorted into `lines`, and its selection, scroll and order. +@kwdef mutable struct Tab + title::String + columns::Tuple + rows::Vector{Row} = Row[] + lines::Vector{Row} = Row[] selected::Int = 0 offset::Int = 0 sortcol::Int = 1 reverse::Bool = false +end + +sorts(t::Tab) = (NAME, filter(c -> c.by !== nothing, t.columns)...) + +@kwdef mutable struct Dashboard <: Model + interval::Float64 = 1.0 + filter::String = "" # by function name, on both tabs + tabs::Vector{Tab} = [Tab(; title = "RAM", columns = RAMCOLUMNS), Tab(; title = "Disk", columns = DISKCOLUMNS)] + tab::Int = 1 # 1: RAM, 2: Disk + trends::IdDict{Any, Trend} = IdDict{Any, Trend}() # by cache, or by function for the disk lastrefresh::Float64 = -Inf - pending::Union{Nothing, Int} = nothing # new maxsize while resizing - input::Union{Nothing, TextInput} = nothing # filter input + pending::Union{Nothing, Int} = nothing # new maxsize while resizing + input::Union{Nothing, TextInput} = nothing # filter input message::String = "" + diskinfo::IdDict{Any, Any} = IdDict{Any, Any}() # f => (; path, entries, bytes), or `nothing` if unreadable + diskread::Float64 = -Inf # when the last read of `diskinfo` started + disktask::Union{Nothing, Task} = nothing # reading the next `diskinfo` quit::Bool = false end @@ -81,24 +113,35 @@ function Cached.cache_dashboard(; interval::Real = 1.0, filter::AbstractString = return nothing end -selected(m::Dashboard) = get(m.lines, m.selected, nothing) +current(m::Dashboard) = m.tabs[m.tab] +isdisk(m::Dashboard) = m.tab == 2 +selected(m::Dashboard) = (t = current(m); get(t.lines, t.selected, nothing)) # --- refresh --- function refresh!(m::Dashboard, now = time()) elapsed = now - m.lastrefresh m.lastrefresh = now - trends = IdDict{AbstractCache, Trend}() - m.rows = map(cache_info()) do (f, c) + trends = IdDict{Any, Trend}() + disk = disk_stats() + m.tabs[1].rows = map(cache_info()) do (f, c) st = cache_stats(c) s = STATS(st[fieldnames(STATS)]) - kind = c isa Cached.ClockCache ? "Clock" : string(nameof(typeof(c))) + kind = (c isa Cached.ClockCache ? "Clock" : string(nameof(typeof(c)))) * (any(d -> first(d) === f, disk) ? "+disk" : "") Row(f, c, repr(f), kind, st.by !== nothing, s, trend!(trends, m.trends, c, s, elapsed)) end + m.tabs[2].rows = map(disk) do (f, d) + s = STATS((d.hits, d.misses, 0, 0, 0)) + Row(f, nothing, repr(f), "Disk", false, s, trend!(trends, m.trends, f, s, elapsed)) + end m.trends = trends # drops the trends of caches that are gone return rebuild!(m) end +# Without the SQLite extension there are no disk caches. +hassqlite() = applicable(Cached.disk_cache_stats) +disk_stats() = hassqlite() ? Cached.disk_cache_stats() : Pair{Any, @NamedTuple{hits::Int, misses::Int}}[] + function trend!(trends, old, c, s, elapsed) t = get(old, c, nothing) if t === nothing @@ -113,46 +156,97 @@ function trend!(trends, old, c, s, elapsed) return trends[c] = t end -# Filter and sort `rows` into `lines`, keeping the selected cache selected. +# Filter and sort the rows of each tab into its lines, keeping the selected row selected. function rebuild!(m::Dashboard) - current = selected(m) - m.lines = sort!(filter(r -> occursin(m.filter, r.label), m.rows); by = last(SORTS[m.sortcol]), rev = m.reverse) - i = current === nothing ? nothing : findfirst(r -> r.cache === current.cache, m.lines) - m.selected = clamp(something(i, m.selected), min(1, length(m.lines)), length(m.lines)) + for t in m.tabs + sel = get(t.lines, t.selected, nothing) + by = sorts(t)[t.sortcol].by + t.lines = sort!(filter(r -> occursin(m.filter, r.label), t.rows); by = r -> by(m, r), rev = t.reverse) + i = sel === nothing ? nothing : findfirst(r -> id(r) === id(sel), t.lines) + t.selected = clamp(something(i, t.selected), min(1, length(t.lines)), length(t.lines)) + end + return m +end + +# --- the entries and size on disk --- + +# `missing` until read, `nothing` if it could not be. +diskinfo(m::Dashboard, r::Row) = get(m.diskinfo, r.f, missing) + +# Collect a finished read, and start the next one at most every `DISKINTERVAL` seconds, or +# when a disk cache has not been read yet. Counting the entries of a large database (on a +# network file system, or while another process writes) can take a while, so it never runs +# on the frame. +function read_disk!(m::Dashboard) + task = m.disktask + if task !== nothing && istaskdone(task) + m.diskinfo, m.disktask = fetch(task), nothing + rebuild!(m) # the order may depend on it + end + rows = m.tabs[2].rows + if m.disktask === nothing && !isempty(rows) && (time() - m.diskread >= DISKINTERVAL || any(r -> !haskey(m.diskinfo, r.f), rows)) + m.diskread = time() + fs = map(r -> r.f, rows) + m.disktask = Threads.@spawn IdDict{Any, Any}(f => read_diskinfo(f) for f in fs) + end return m end +function read_diskinfo(f) + return try + info = Cached.disk_cache_info(f) + isempty(info) ? nothing : last(first(info)) + catch + nothing + end +end + +# A cell of the Disk tab: `…` until read, `?` if it could not be. +function disk_cell(m::Dashboard, r::Row, show, rect::Rect, buf) + info = diskinfo(m, r) + s = info === missing ? "…" : info === nothing ? "?" : show(info) + return set_string!(buf, rect.x, rect.y, s, tstyle(:text); max_x = right(rect)) +end +disk_key(m::Dashboard, r::Row, field) = (info = diskinfo(m, r); info isa NamedTuple ? getfield(info, field) : -1) + # --- events --- function Tachikoma.update!(m::Dashboard, evt::KeyEvent) m.input === nothing || return edit_filter!(m, evt) m.pending === nothing || return edit_size!(m, evt) key, c = evt.key, evt.char - r = selected(m) + t, r = current(m), selected(m) m.message = "" - n = length(m.lines) + n = length(t.lines) if key == :escape || (key == :char && c == 'q') m.quit = true + elseif key in (:tab, :backtab) + m.tab = mod1(m.tab + 1, length(m.tabs)) + elseif key == :char && c in "12" + m.tab = c - '0' elseif key in (:up, :down, :pageup, :pagedown, :home, :end_key) step = (; up = -1, down = 1, pageup = -10, pagedown = 10, home = -n, end_key = n)[key] - m.selected = clamp(m.selected + step, min(1, n), n) + t.selected = clamp(t.selected + step, min(1, n), n) elseif key == :char && c == 's' - m.sortcol = mod1(m.sortcol + 1, length(SORTS)) + t.sortcol = mod1(t.sortcol + 1, length(sorts(t))) rebuild!(m) elseif key == :char && c == 'r' - m.reverse = !m.reverse + t.reverse = !t.reverse rebuild!(m) elseif key == :char && c == '/' m.input = TextInput(; text = m.filter, label = "Filter: ", focused = true) elseif key == :char && c == 'g' + m.diskread = -Inf refresh!(m) elseif r === nothing nothing + elseif isdisk(m) && (key == :enter || (key == :char && c == 'e')) + m.message = "disk caches are persistent and shared: empty them with empty_disk_caches!" elseif key == :enter m.pending = r.stats.maxsize elseif key == :char && c == 'e' empty!(r.cache) - m.message = "emptied $(r.label)" + m.message = "emptied $(r.label)" * (endswith(r.kind, "+disk") ? " in RAM; its disk cache is kept" : "") refresh!(m) end return nothing @@ -202,6 +296,7 @@ end function Tachikoma.view(m::Dashboard, f::Frame) time() - m.lastrefresh >= m.interval && refresh!(m) + isdisk(m) && read_disk!(m) area, buf = f.area, f.buffer if area.width < MINSIZE[1] || area.height < MINSIZE[2] msg = "terminal too small (need $(MINSIZE[1])×$(MINSIZE[2]))" @@ -209,62 +304,73 @@ function Tachikoma.view(m::Dashboard, f::Frame) set_string!(buf, r.x, r.y, msg, tstyle(:warning, bold = true); max_x = right(r)) return end - detail = area.height >= 16 ? 5 : 0 - header, body, info, status = split_layout(Layout(Vertical, [Fixed(1), Fill(), Fixed(detail), Fixed(1)]), area) + t, r = current(m), selected(m) + detail = area.height < 16 ? 0 : 5 + tabs, header, body, info, status = split_layout(Layout(Vertical, [Fixed(1), Fixed(1), Fill(), Fixed(detail), Fixed(1)]), area) - entries = sum(r -> r.stats.length, m.rows; init = 0) - filt = isempty(m.filter) ? "" : " · filter \"$(m.filter)\"" - title = " $(length(m.rows)) caches, $entries entries$filt" - set_string!(buf, header.x, header.y, title, tstyle(:title, bold = true); max_x = right(header)) + render(TabBar([t.title for t in m.tabs]; active = m.tab), tabs, buf) + set_string!(buf, header.x, header.y, " " * totals(m), tstyle(:title, bold = true); max_x = right(header)) - if isempty(m.lines) - msg = isempty(m.rows) ? "No global caches yet. Call a @cached function." : "No caches match the filter." + if isempty(t.lines) + msg = !isempty(t.rows) ? "No caches match the filter." : + !isdisk(m) ? "No global caches yet. Call a @cached function." : + hassqlite() ? "No disk caches open yet. Call a function with a DiskCache." : "Load SQLite.jl (using SQLite) to use disk caches." set_string!(buf, body.x + 1, body.y, msg, tstyle(:text_dim); max_x = right(body)) else - render_table(m, body, buf) + render_table(m, t, body, buf) end - r = selected(m) - detail > 0 && r !== nothing && render_detail(r, info, buf) + detail > 0 && r !== nothing && render_detail(m, r, info, buf) render_status(m, status, buf) return nothing end -# Optional columns, dropped from the right as the terminal narrows: header, width, and a -# function drawing the cell into a rect. -const COLUMNS = ( - ("Hit rate", 13, (rect, buf, r, m) -> render(hitrate_gauge(r), rect, buf)), - ("Size", 22, (rect, buf, r, m) -> render(size_gauge(r, m), rect, buf)), - ("Kind", 5, (rect, buf, r, m) -> set_string!(buf, rect.x, rect.y, r.kind, tstyle(:text_dim))), - ("Activity", 8, (rect, buf, r, m) -> render(Sparkline(r.trend.rates; style = tstyle(:accent)), rect, buf)), -) +# The header of the current tab, over all its rows (also those hidden by the filter). +function totals(m::Dashboard) + rows = current(m).rows + filt = isempty(m.filter) ? "" : " · filter \"$(m.filter)\"" + if !isdisk(m) + bytes = filter(r -> r.bytes, rows) + size = isempty(bytes) ? "" : " · $(_short(sum(r -> r.stats.currentsize, bytes), true)) in byte-measured caches" + return "$(length(rows)) caches · $(sum(r -> r.stats.length, rows; init = 0)) entries$size · recent hit rate $(_percent(recent(rows)))$filt" + end + hassqlite() || return "no disk caches: SQLite.jl is not loaded" + infos = map(r -> diskinfo(m, r), rows) + known = filter(i -> i isa NamedTuple, infos) + stored = any(ismissing, infos) ? "… entries · …" : + "$(sum(i -> i.entries, known; init = 0)) entries · $(_short(sum(i -> i.bytes, known; init = 0), true))" + hits, misses = sum(r -> r.stats.hits, rows; init = 0), sum(r -> r.stats.misses, rows; init = 0) + off = Cached.DISK_ENABLED[] ? "" : " · disk caches off" + return "$(length(rows)) disk caches · $stored · $hits hits · $misses misses ($(_percent(hitrate((; hits, misses)))))$off$filt" +end + const MINNAME = 20 # width of the name column below which columns are dropped -function render_table(m::Dashboard, area::Rect, buf) - widths = cumsum([c[2] + 1 for c in COLUMNS]) +function render_table(m::Dashboard, t::Tab, area::Rect, buf) + widths = cumsum([c.width + 1 for c in t.columns]) k = count(<=(area.width - 1 - MINNAME), widths) # as wide as the longest label (over all lines, so that scrolling keeps the layout), and # any spare width is left at the right - longest = maximum(r -> textwidth(r.label), m.lines; init = 0) + longest = maximum(r -> textwidth(r.label), t.lines; init = 0) namewidth = clamp(longest, MINNAME, area.width - 1 - (k == 0 ? 0 : widths[k])) - cells = (("Name", namewidth, nothing), COLUMNS[1:k]...) + cells = ((; NAME..., width = namewidth), t.columns[1:k]...) xs = [area.x + 1; [area.x + namewidth + 2 + (j == 1 ? 0 : widths[j - 1]) for j in 1:k]] - sorted, arrow = first(SORTS[m.sortcol]), m.reverse ? " ▼" : " ▲" - for ((name, w, _), x) in zip(cells, xs) - set_string!(buf, x, area.y, name == sorted ? name * arrow : name, tstyle(:title, bold = true); max_x = x + w - 1) + sorted, arrow = sorts(t)[t.sortcol].name, t.reverse ? " ▼" : " ▲" + for (c, x) in zip(cells, xs) + set_string!(buf, x, area.y, c.name == sorted ? c.name * arrow : c.name, tstyle(:title, bold = true); max_x = x + c.width - 1) end height = area.height - 1 # keep the selection visible, without blank rows below while rows above are hidden - m.offset = clamp(m.offset, max(m.selected - height, 0), max(min(m.selected - 1, length(m.lines) - height), 0)) - for (i, r) in enumerate(m.lines[(m.offset + 1):min(end, m.offset + height)]) + t.offset = clamp(t.offset, max(t.selected - height, 0), max(min(t.selected - 1, length(t.lines) - height), 0)) + for (i, r) in enumerate(t.lines[(t.offset + 1):min(end, t.offset + height)]) y = area.y + i - issel = m.offset + i == m.selected + issel = t.offset + i == t.selected style = issel ? tstyle(:accent, bold = true) : tstyle(:text) issel && set_char!(buf, area.x, y, '▌', style) set_string!(buf, area.x + 1, y, _truncate(r.label, namewidth), style) - for ((_, w, draw), x) in zip(cells[2:end], xs[2:end]) - draw(Rect(x, y, w, 1), buf, r, m) + for (c, x) in zip(cells[2:end], xs[2:end]) + c.draw(Rect(x, y, c.width, 1), buf, r, m) end end return @@ -287,21 +393,28 @@ function size_gauge(r::Row, m::Dashboard) return Gauge(frac; label, filled_style = style) end -function render_detail(r::Row, area::Rect, buf) +function render_detail(m::Dashboard, r::Row, area::Rect, buf) inner = render(Block(; title = " $(_truncate(r.label, area.width - 6)) ", border_style = tstyle(:border)), area, buf) s, t = r.stats, r.trend - size = r.bytes ? "$(s.length) entries, $(_short(s.currentsize, true)) of $(_short(s.maxsize, true))" : - "$(s.length) of $(s.maxsize) entries" - lines = ( - "$(r.kind) · $size · $(s.hits) hits · $(s.misses) misses", - "hit rate: recent $(_percent(recent(t))) · lifetime $(_percent(hitrate(s))) · $(round(activity(r); digits = 1)) hits/s", - ) - render(Paragraph(join(lines, "\n"); style = tstyle(:text)), Rect(inner.x, inner.y, inner.width, 2), buf) + rates = "hit rate: recent $(_percent(recent(t))) · lifetime $(_percent(hitrate(s))) · $(round(activity(r); digits = 1)) hits/s" + lines = if isdisk(m) + info = diskinfo(m, r) + stored = info === missing ? "… entries" : info === nothing ? "? entries (could not be read)" : + "$(info.entries) entries · $(_short(info.bytes, true)) · $(info.path)" + [stored, "$(s.hits) hits · $(s.misses) misses in this process · $rates"] + else + size = r.bytes ? "$(s.length) entries, $(_short(s.currentsize, true)) of $(_short(s.maxsize, true))" : + "$(s.length) of $(s.maxsize) entries" + ["$(r.kind) · $size · $(s.hits) hits · $(s.misses) misses", rates] + end + # cut in the middle rather than wrapped, which keeps the end of the path + render(Paragraph(join(_truncate.(lines, inner.width), "\n"); style = tstyle(:text)), Rect(inner.x, inner.y, inner.width, 2), buf) render(Sparkline(t.rates; style = tstyle(:accent)), Rect(inner.x, inner.y + 2, inner.width, 1), buf) return end -const KEYS = "↑↓ select ⏎ resize e empty s sort r reverse / filter g refresh q quit" +const KEYS = "⇥ tab ↑↓ select ⏎ resize e empty s sort r reverse / filter g refresh q quit" +const DISKKEYS = "⇥ tab ↑↓ select s sort r reverse / filter g refresh q quit" const RESIZEKEYS = "←→ ½ ×2 [ ] ±10% ⏎ apply Esc cancel" function render_status(m::Dashboard, area::Rect, buf) @@ -310,12 +423,11 @@ function render_status(m::Dashboard, area::Rect, buf) render(m.input, area, buf) elseif m.pending !== nothing && r !== nothing change = " $(_short(r.stats.maxsize, r.bytes)) → $(_short(m.pending, r.bytes)) " - what = " $(r.label) " - left = [Span(change, tstyle(:accent, bold = true)), Span(what, tstyle(:text)), Span(" $RESIZEKEYS", tstyle(:text_dim))] + left = [Span(change, tstyle(:accent, bold = true)), Span(" $(r.label) ", tstyle(:text)), Span(" $RESIZEKEYS", tstyle(:text_dim))] render(StatusBar(; left), area, buf) else # a message replaces the keybindings until the next key - left = isempty(m.message) ? Span(" $KEYS ", tstyle(:text_dim)) : Span(" $(m.message) ", tstyle(:warning)) + left = isempty(m.message) ? Span(" $(isdisk(m) ? DISKKEYS : KEYS) ", tstyle(:text_dim)) : Span(" $(m.message) ", tstyle(:warning)) render(StatusBar(; left = [left]), area, buf) end return diff --git a/research/design.md b/research/design.md index 3b8c239..6737825 100644 --- a/research/design.md +++ b/research/design.md @@ -1,152 +1,86 @@ -# Design (v0.1) +# Design -Decisions for the rewrite, agreed on 2026-10-01. -Background and the rejected alternatives are in the other notes in this folder. -Items marked **open** still need a decision. +The decisions behind Cached.jl and the invariants the code relies on. User-facing details are +in `docs/src`; the prototype notes in this folder hold the rejected alternatives. ## Goals -- Replace TensorKit's internal `@cached` and the hand-rolled caches in SUNRepresentations.jl: `CGC_CACHE`, `REDUCED_CGC_CACHE`, `FCACHE`/`FUCACHE`. -- **Full type stability when it is available.** For a type-stable function called with concrete argument types, the call infers to the value type `V`, and a cache hit allocates nothing. -- **Full argument support**: any number of positional arguments, varargs, default values, and keyword arguments. -- **Precompilation-safe**: no `Core.eval`, no methods defined at runtime, no evaluation into other modules. -- **Small and maintainable.** All strategy logic is ordinary code in Cached, and the macro does only syntax rewriting. +- Replace TensorKit's internal `@cached` and the hand-rolled caches of SUNRepresentations.jl. +- Type stability: a type-stable function called with concrete arguments infers to its value + type, and a cache hit allocates nothing. +- Full argument support: positional, default, varargs and keyword arguments. +- Precompilation-safe: no `Core.eval` or runtime method definitions in the call path. +- Small and maintainable: the macro only rewrites syntax; all logic is ordinary code. -## Non-goals for v0.1 +Non-goal: a memory budget per process. -- A disk-backed cache. A later extension will generalize SUNRepresentations' JLD2 + Scratch + Pidfile setup. -- A memory budget per process. +## Macro -## Macro expansion (approach C) +`@cached` moves the body into `Cached.implementation(::typeof(f), args...; kw...)`, with +defaults removed, and leaves `f` with its original signature calling +`Cached.call(f, CacheStyle(f, args...), V, args, kw)`. Julia's lowering handles defaults and +keywords, and dispatch on `typeof(f)` covers qualified names, operators and callable objects. -The user's body moves into a method of a function that Cached owns, dispatching on `typeof(f)`. -The user's own `f` keeps its exact signature, so Julia's lowering handles default values and keywords. - -```julia -@cached function f(a::A, b::B = b0; k = 1)::R where {T} - body -end -``` - -expands to approximately: - -```julia -function Cached.implementation(::typeof(f), a::A, b::B; k) where {T} # defaults removed; kwarg made required - body -end - -function f(a::A, b::B = b0; k = 1) where {T} # user signature, verbatim - return Cached.call(f, CacheStyle(f, a, b), R, (a, b), (; k))::R -end -``` - -- The key is always the tuple of positional arguments, with the keyword `NamedTuple` appended when there are keywords. So `K = Tuple{typeof.(args)..., typeof(kwargs)}`. -- **`V`** is the return annotation when there is one. It is evaluated inside the method, so it may depend on `where` parameters, as `_fsdicttype(K)` does. Without an annotation, `Cached.call` uses `Core.Compiler.return_type` on `implementation` and falls back to `Any` when the result is not concrete. -- **`Cached.uncached(f, args...; kw...)`** calls `implementation` directly. It replaces the old `f(NoCache(), x)`. -- **Qualified names, operators, and functors** all work the same way, because dispatch is on `typeof(f)`. Examples are `TensorKitSectors.Fsymbol`, `Base.:*`, and `(x::Foo)(y)`. -- **Duplicate `@cached` on the same method** is no longer a separate concern. It is ordinary method overwriting, which Julia already reports, so no registry check is needed. +- The key is the tuple of positional arguments, plus the keyword `NamedTuple` if any. +- `V` is the return annotation, or `Core.Compiler.return_type` of `implementation`, falling back + to `Any` when not concrete. The result is asserted `::V`, which keeps calls inferred. ## Strategies -These keep TensorKit's names: - -```julia -abstract type CacheStyle end -struct NoCache <: CacheStyle end -struct TaskLocalCache{C} <: CacheStyle end # C: a cache container type, or an AbstractDict -struct GlobalCache{C} <: CacheStyle end -const GlobalLRUCache = GlobalCache{LRU} - -CacheStyle(f, args...) = GlobalLRUCache() # default; users specialize per function and argument type -``` - -`CacheStyle` only sees positional arguments, because keywords do not take part in dispatch. +`CacheStyle(f, args...)` picks `NoCache`, `GlobalCache{C}` or `TaskLocalCache{C}` per function +and argument type; the default is `GlobalCache()`, whose container is the compile-time +`container` preference (`ClockCache`). `DiskCacheStyle` (below) is independent of it. -`Cached.call` has one method per strategy: - -- **`NoCache`**: calls `implementation(f, args...; kw...)`. -- **`GlobalCache{C}`** and **`TaskLocalCache{C}`**: `get!(cache, key) do implementation(...) end::V`, where `cache` is the function's single `C{Any,Any}`. - - Revised on 2026-10-01, after the TensorKit integration. Each function originally had one typed sub-cache `C{K,V}` per key and value type. That bounded memory poorly: TensorKit reached about 50 sub-caches per function, and a single function's entries could exceed its limit by 100×. - - Calls stay inferred because of the `::V` assertion, not because of the cache type. The lookup does not box the key either, thanks to the probe keys described under Containers. - - Measured on `main` against this design: hits take the same time (35 vs 34 ns for `Int` keys; 126 vs 124 ns for TensorKit-like 4-leg tree keys), with zero allocation in both. - - For `TaskLocalCache`, the table is stored in `task_local_storage()`. A `Dict` would box untyped keys, so it is still typed per key and value type. +- Each function has **one** global `C{Any,Any}` cache, which is its memory budget. Typed + sub-caches per signature bounded memory poorly (TensorKit reached ~50 per function). +- Task-local caches live in `task_local_storage()` and are not registered. ## Containers -Cached provides its own containers, so it no longer depends on LRUCache.jl. -Both are `AbstractDict`s with `get!`, `get`, `haskey`, `delete!`, `empty!`, `resize!`, `length`, iteration, and hit/miss statistics. - -- **Minimal public interface** (revised on 2026-10-03). A custom `AbstractCache` implements only what the machinery calls: `C{K,V}(; maxsize, by)`, `get!`, `empty!`, `resize!` and `cache_stats`, thread-safely, without holding a lock while computing a value (`docs/src/interface.md`). `show` derives from `cache_stats`; the `AbstractDict` methods are optional. - - `AbstractCache <: AbstractDict` is kept so that `LRU` and `ClockCache` stay dictionaries; dropping it would make a custom cache honest about not being one, at the cost of a breaking change. -- **Shared implementation, internal.** `LRU` and `ClockCache` subtype the internal `SlotCache`, which implements every method once on a `Slots{V}` field (keys, values and sizes in numbered slots with a free list, the index, the size accounting, the statistics and the lock). Each is an eviction policy over slot numbers, in four hooks called with the lock held: `admit!`, `touch!`, `victim` and `forget!`. `empty!` frees every slot through `forget!`; iteration order is unspecified (`LRU` iterated in recency order before). - -- **`LRU{K,V}`** is array-backed. It uses a `Dict` index, slots stored in vectors, and `prev`/`next` stored as integer vectors. Nodes are never allocated, and eviction is exact LRU. -- **The index is a `Dict{Key{Any},Int}`.** Each stored `Key{Any}` holds the key and its hash. Lookups probe it with a concretely typed `Key{K}`, whose `isequal` checks `s.key isa K` before comparing, so the comparison is static and the key is never boxed. A `Key{Any}` cannot serve as the probe: storing the key in an `Any` field boxes it (96 B per lookup on TensorKit-like keys). - - This holds for `C{Any,Any}` as well, where a plain `Dict{Any,…}` would allocate on every hit. - - Eviction never re-hashes, because the hash is stored. - - Keys of different types are different entries, even when `isequal` (`f(3)` and `f(3.0)`). Lookups on a typed cache convert the key to `K` first. -- **`ClockCache{K,V}`** uses second-chance eviction: a ring of slots with a reference bit. A hit only sets the bit and never reorders the ring, which makes it cheap for read-heavy shared caches. -- Each container takes a size limit, either a **count** or **bytes** measured by a `by` function. Each one holds its own lock. Task-local containers skip the lock. -- **Default for `GlobalCache`: `ClockCache`.** - - On synthetic workloads (`benchmark/containers.jl`), it beat `LRU` everywhere single-threaded (28 vs 32 ns for an all-hit lookup) and had a slightly better hit rate under Zipf access. Both were 1.5–3× faster than LRUCache.jl. - - On the TensorKit and SUNRepresentations integration, the two were indistinguishable. - - It stays the default because a hit only sets a bit, which makes lock-free hits possible. -- **open**: contention. With 8 threads, every container's per-lookup cost *rises* (all hits: about 50–75 ns, against 28–32 ns single-threaded), because every lookup takes the cache's single lock. - Sharding by key hash, or lock-free reads for `ClockCache`, are the candidate fixes. `TaskLocalCache` is the workaround meanwhile. - -## Limits - -**One budget per function**: the `maxsize` of its single cache, as a count or in bytes, covering all signatures together. The default is 10,000 entries, the same as TensorKit's caches before. - -```julia -set_cache_size!(f, n; by = nothing) -``` - -The earlier per-sub-cache limit, the cap on the number of sub-caches (`maxsubcaches`, `set_max_subcaches!`) and the first-in-first-out dropping of whole sub-caches are gone. - -Defaults come from Preferences (below). +- The public `AbstractCache` interface is only what the machinery calls (`docs/src/interface.md`). + `AbstractCache <: AbstractDict` is kept so `LRU` and `ClockCache` stay dictionaries. +- `LRU` and `ClockCache` share an internal `SlotCache` implementation on a `Slots` field and + differ only in four eviction hooks (`admit!`, `touch!`, `victim`, `forget!`). +- The index is a `Dict{Key{Any},Int}` holding each key with its hash; lookups probe with a + concrete `Key{K}` whose `isequal` checks the type first, so hits never box the key. Keys of + different types are different entries (`f(3)` and `f(3.0)`). +- One lock per cache; `get!` computes outside it, so functions may recurse into their own cache. +- Lock contention on shared caches with cheap keys is real in microbenchmarks but did not show in + TensorKit's workloads. Lock-free `ClockCache` hits are parked on the `lockfree-clock` branch + (#9); `TaskLocalCache` is the workaround. -## Registry and introspection +## Registry -- Caches are created at runtime, on the first call of each function. There is one per function, or one per container type if its `CacheStyle` selects several. Nothing is registered at load time. -- **Lookups take no lock.** The hot path reads an immutable snapshot of an `IdDict` through an atomic field. Creating a cache rebuilds the snapshot under a lock, which is rare: it happens once per function. -- **The lookup key is a constant type** when `f` is a singleton function: `Tuple{typeof(f), C{Any,Any}}`. Its hash is cached, so the lookup is cheap. Callable objects with fields fall back to an `(f, C)` tuple key, so each distinct instance gets its own cache. -- `cache_info([f])` returns the live caches as `f => cache` pairs. Containers `show` as a one-line summary (type, size against the limit, hits, misses), so no separate summary type is needed, and the pairs can be emptied or resized directly. Container iteration walks a snapshot taken under the lock, so it is thread-safe. -- `empty_caches!()` empties every cache, and `empty_caches!(f)` empties the caches of `f`. -- Task-local tables are not visible to `cache_info`. **open**: whether that matters. +Caches are created on a function's first call. The hot path reads an immutable `IdDict` +snapshot through an atomic field, without locking; creating a cache republishes the snapshot +under a lock. For singleton functions the lookup key is a constant type, so its hash is cached. ## Configuration -Decided on 2026-10-01: per package **and** per function, through Preferences.jl. The full description is in `docs/src/configuration.md`. - -- Settings are `maxsize` and `measure` (`"count"` or `"bytes"`, the latter using `Cached.cachesize`). - - They are resolved once per function, when its first cache is created, in this order: runtime calls, then `[.Cached.]`, then `[.Cached]`, then `[Cached]`, then the built-in defaults. - - `` is the package of `parentmodule(typeof(f))`. For functions extended by several packages, that is the owner of the function, not the extending packages. - - Reading them at runtime means they cost nothing at compile time and need no recompilation of user packages. Task-local caches use the same settings. -- `container` (`"ClockCache"` or `"LRU"`) is a compile-time preference, `[Cached]` only, because it selects the default `CacheStyle`, which must be a constant. -- `set_cache_preferences!` writes any of these sections, choosing the section from its argument: nothing for `[Cached]`, a package module, or a function. It merges with the existing tables, and a value of `nothing` removes a setting. -- `measure = "bytes"` uses `Cached.cachesize(x)`, which defaults to `Base.summarysize` and is meant to be overloaded per value type. - -## Hooks and extensions - -- **Timing** (`CachedTimerOutputsExt`): `enable_cache_timers!(M, timer = get_defaulttimer())` and `disable_cache_timers!(M)`. The user-facing description is `docs/src/timing.md`. - - Internally, calls go through `instrument(f, ::Val{phase}, thunk, ::Val{owner})`, `@inline`, default `thunk()`: it compiles away, with no load and no branch, and hits stay at 0 allocations. It is not a public extension point; only the TimerOutputs extension adds methods. - - Two nested phases, after TensorKit's `@timeit_debug` sections: `:lookup` wraps the whole `get!` of a global or task-local cache, `:compute` wraps the `implementation` call (nested in `:lookup` on a miss, alone under `NoCache`). `uncached` is not instrumented. - - `owner` is `fullname` of the package owning `f` (`parentmodule(typeof(f))`, walked up to the package root; modules outside packages own their functions themselves), folded at compile time by a `@generated` function. Enabling a package covers its submodules, and enabling TensorKitSectors times the `@cached Fsymbol` methods of SUNRepresentations too. - - Timing is a debugging feature and may recompile, like TimerOutputs' `enable_debug_timings(mod)`. A runtime switch (a `Ref`) cost a load and a branch on every call, and was dropped. - - `enable_cache_timers!` evals `instrument(f, phase, thunk, ::Val{owner}) = timeit(thunk, timer, instrument_label(f, phase))` into the extension module, invalidating only the callers of functions owned by that package. `disable_cache_timers!` deletes it with `Base.delete_method`, after which the callers recompile to the default (verified on 1.10 and 1.13: hits inferred, 0 allocations). Deleting a method that overwrote another revives the old one, so enabling again deletes the previous method first. Refuses to run during precompilation. Labels come from the public `Cached.instrument_label(f, ::Val{phase})`, `"lookup f"`/`"compute f"` by default. - - `f::F` static parameters in `call.jl` keep Julia specializing on `f`, otherwise the closures box it. The owner is computed in `_call`, outside the closures: computing it inside made Julia 1.10, and only 1.10, box the closure and key (32 B per hit) in the first caller compiled that inlines the hit. - - World age: the new method is seen from the next top-level statement on, or through `invokelatest`. - - Without TimerOutputs, calling either function gives a `MethodError` with an error hint, like `cache_dashboard`. -- **`CachedTachikomaExt`**: a TUI for browsing caches, watching hit rates live, resizing, and emptying. Implemented as `cache_dashboard()`; the user-facing description is `docs/src/dashboard.md`. - - One row per cache, with Tachikoma `Gauge`s for the recent hit rate (last 10 refreshes) and the size against the limit, the container kind, and an activity sparkline. - - Resizing calls `set_cache_size!` with the function's current measure. - - It renders from a copy of the statistics, so it never holds a cache's lock across frames. - - Without Tachikoma, calling it gives a `MethodError` with an error hint. -- **Disk extension**: deferred. See non-goals. +`maxsize` and `measure` (and the disk settings) come from Preferences.jl, resolved once per +function at runtime: runtime calls, then `[.Cached.]`, `[.Cached]`, +`[Cached]`, built-in defaults. `` owns `parentmodule(typeof(f))`. Only `container` is +compile-time. See `docs/src/configuration.md`. + +## Extensions + +- **Timing** (`CachedTimerOutputsExt`, `docs/src/timing.md`). Calls go through an internal + `instrument(f, phase, thunk, ::Val{owner})` that compiles to `thunk()`. Enabling a package + evals one method for its owner tuple into the extension (recompiling only its callers); + disabling deletes it. Enabling first deletes any existing method, since deleting an + overwriting method revives the old one. The owner is computed outside the closures: + otherwise Julia 1.10 boxes them. +- **Dashboard** (`CachedTachikomaExt`, `docs/src/dashboard.md`). Renders from copied statistics, + never holding a cache lock across frames. Tabs for RAM and disk; the Disk tab uses in-memory + counters, and reads entries and sizes (`disk_cache_info`) in a background task, never on a + frame. Emptying and resizing act on RAM only. +- **Disk** (`CachedSQLiteExt`, `docs/src/disk.md`). Below RAM: RAM, then artifact, then the + node's SQLite database, then compute. One WAL-mode database per function, version and host, + shared by the node's processes. `diskversion(f)` is the only version: keeping stored results + valid (including across Julia versions) is the user's responsibility. Functions without a + disk style compile exactly as before. +- Each extension's entry points are stubs in the core with an error hint. ## Open questions -- ~~**Functors**~~: resolved. The instance is passed to `implementation`, and instances with fields get their own cache, so the budget applies per instance. -- **Revise**: should redefining a cached method empty that function's caches? -- ~~**Inference**~~: resolved. `return_type` is the default for unannotated functions, falling back to `Any` when the result is not concrete. It folds at compile time, so a hit is fully inferred and allocates nothing. +- Should redefining a cached method (Revise) empty its caches? diff --git a/src/Cached.jl b/src/Cached.jl index e8d4be9..0a5e9f4 100644 --- a/src/Cached.jl +++ b/src/Cached.jl @@ -5,15 +5,20 @@ export CacheStyle, NoCache, GlobalCache, GlobalLRUCache, TaskLocalCache export LRU, ClockCache export cache_info, empty_caches!, set_cache_size!, set_cache_preferences! export cache_dashboard, enable_cache_timers!, disable_cache_timers! +export DiskCacheStyle, DiskCache, disk_cache_info, empty_disk_caches!, export_disk_cache +export disable_disk_caches!, enable_disk_caches! # documented API that is used qualified, e.g. by overloading `Cached.cachesize` @static if VERSION >= v"1.11.0-DEV.469" - eval(Meta.parse("public AbstractCache, cache_stats, cachesize, implementation, instrument_label")) + eval(Meta.parse("public AbstractCache, cache_stats, disk_cache_stats, cachesize, implementation, instrument_label, diskversion, disk_artifact")) end using Base: @lock using ExprTools: ExprTools using Preferences: @load_preference, load_preference, has_preference, set_preferences!, delete_preferences! +using Serialization: AbstractSerializer, Serializer +using Scratch: Scratch, get_scratch! # with SHA, for the SQLite extension +using SHA: sha256 include("containers/interface.jl") include("containers/slots.jl") @@ -23,6 +28,7 @@ include("containers/clock.jl") include("registry.jl") include("preferences.jl") include("cachestyle.jl") +include("disk.jl") include("call.jl") include("api.jl") include("macro.jl") diff --git a/src/api.jl b/src/api.jl index 8bb7fbb..4959c8a 100644 --- a/src/api.jl +++ b/src/api.jl @@ -59,7 +59,8 @@ end cache_dashboard(; interval = 1.0, filter = "") Open an interactive terminal dashboard of the global caches: one row per function, with -live hit rates and sizes, from which caches can be emptied or resized. The +live hit rates and sizes, from which caches can be emptied or resized. A second tab lists the +disk caches in use (see the disk caching docs), with their entries, size and hit rate. The statistics are re-read every `interval` seconds, and only functions whose name contains `filter` are shown. @@ -100,6 +101,11 @@ const EXTENSIONS = ( cache_dashboard => (:CachedTachikomaExt, "Tachikoma"), enable_cache_timers! => (:CachedTimerOutputsExt, "TimerOutputs"), disable_cache_timers! => (:CachedTimerOutputsExt, "TimerOutputs"), + disk_lookup => (:CachedSQLiteExt, "SQLite"), + disk_cache_info => (:CachedSQLiteExt, "SQLite"), + disk_cache_stats => (:CachedSQLiteExt, "SQLite"), + empty_disk_caches! => (:CachedSQLiteExt, "SQLite"), + export_disk_cache => (:CachedSQLiteExt, "SQLite"), ) # Without the extension these functions have no methods; say how to get them. Calls with @@ -108,7 +114,7 @@ function _extension_hint(io, exc, argtypes, kwargs) f = exc.f === Core.kwcall && length(exc.args) >= 2 ? exc.args[2] : exc.f for (g, (ext, pkg)) in EXTENSIONS f === g && Base.get_extension(@__MODULE__, ext) === nothing && - print(io, "\n`$(nameof(g))` needs $pkg.jl: run `using $pkg` first.") + print(io, "\n`$(g === disk_lookup ? "DiskCache" : nameof(g))` needs $pkg.jl: run `using $pkg` first.") end return nothing end diff --git a/src/call.jl b/src/call.jl index c564a4f..12fb2c9 100644 --- a/src/call.jl +++ b/src/call.jl @@ -15,8 +15,9 @@ uncached(f, args...; kwargs...) = implementation(f, args...; kwargs...) # Hook around the phases of a call, for `enable_cache_timers!` (TimerOutputs extension): # `:lookup` wraps the whole cache lookup, hit or miss, and `:compute` the implementation call, -# nested in `:lookup` on a miss and alone under `NoCache`. `owner` is the package owning `f`, -# folded at compile time; the extension defines a method per owner, and the default compiles away. +# nested in `:lookup` on a miss and alone under `NoCache`; `:disk` wraps a disk lookup. `owner` +# is the package owning `f`, folded at compile time; the extension defines a method per owner, +# and the default compiles away. @inline instrument(f, phase, thunk, owner) = thunk() # The package containing `m`; modules outside packages (in `Main`) own their functions themselves. @@ -35,10 +36,12 @@ _callimpl(f, args, kw) = implementation(f, args...; kw...) # Entry point of the methods generated by `@cached`. `vt` is the return type annotation, or # `nothing` when there is none, in which case the value type is inferred. +# The disk strategy `DiskCacheStyle(f, args...)` is resolved here too; without disk caching, the +# call is the same as before (`_call`). @inline call(f::F, style::CacheStyle, ::Type{V}, args::Tuple, kw::NamedTuple) where {F, V} = - _call(f, style, V, _key(args, kw), args, kw) + _call(f, style, DiskCacheStyle(f, args...), V, _key(args, kw), args, kw) @inline call(f::F, style::CacheStyle, ::Nothing, args::A, kw::KW) where {F, A <: Tuple, KW <: NamedTuple} = - _call(f, style, _inferred_valtype(Core.Compiler.return_type(_callimpl, Tuple{F, A, KW})), _key(args, kw), args, kw) + _call(f, style, DiskCacheStyle(f, args...), _inferred_valtype(Core.Compiler.return_type(_callimpl, Tuple{F, A, KW})), _key(args, kw), args, kw) _inferred_valtype(::Type{V}) where {V} = isconcretetype(V) ? V : Any @@ -59,6 +62,16 @@ _call(f::F, ::NoCache, ::Type{V}, key, args, kw) where {F, V} = _compute(f, _own return instrument(f, Val(:lookup), () -> get!(() -> _compute(f, o, args, kw), _container(f, style, key, V), key), o)::V end +@inline _call(f::F, style::CacheStyle, ::NoCache, ::Type{V}, key, args, kw) where {F, V} = _call(f, style, V, key, args, kw) + +# With a disk cache, a RAM miss (or every call, without a RAM cache) goes to `_diskcall`. +_call(f::F, ::NoCache, disk::DiskCache, ::Type{V}, key, args, kw) where {F, V} = _diskcall(f, disk, V, key, args, kw)::V + +@inline function _call(f::F, style::CacheStyle, disk::DiskCache, ::Type{V}, key, args, kw) where {F, V} + o = _ownerval(F) + return instrument(f, Val(:lookup), () -> get!(() -> _diskcall(f, disk, V, key, args, kw), _container(f, style, key, V), key), o)::V +end + _container(f::F, ::GlobalCache{C}, key, ::Type) where {F, C} = globalcache(f, C{Any, Any})::C{Any, Any} function _container(f::F, ::TaskLocalCache{C}, key::K, ::Type{V}) where {F, C, K, V} diff --git a/src/disk.jl b/src/disk.jl new file mode 100644 index 0000000..1437030 --- /dev/null +++ b/src/disk.jl @@ -0,0 +1,110 @@ +# Disk caching, a level below the RAM caches (see `docs/src/disk.md`). The core holds the API; +# the stores are implemented by the SQLite extension, `ext/CachedSQLiteExt.jl`. + +""" + DiskCacheStyle(f, args...) + +The disk caching strategy for the call `f(args...)` of a [`@cached`](@ref) function: +[`NoCache()`](@ref NoCache) (the default) or a [`DiskCache`](@ref). It is independent of +[`CacheStyle`](@ref): the disk is consulted when the RAM cache misses, or on every call when +`CacheStyle` is `NoCache()`. + +```julia +Cached.DiskCacheStyle(::typeof(f), args...) = DiskCache() +``` +""" +DiskCacheStyle(f, args...) = NoCache() + +""" + DiskCache{S}() + DiskCache(; serializer = Serialization.Serializer) + +Disk caching strategy, returned by [`DiskCacheStyle`](@ref): results are stored in an SQLite +database per function and node, written with the serializer type `S <: AbstractSerializer`. +Needs SQLite.jl (`using SQLite`). +""" +struct DiskCache{S} + function DiskCache{S}() where {S} + S <: AbstractSerializer || throw(ArgumentError("DiskCache requires an AbstractSerializer, got $S")) + return new{S}() + end +end +DiskCache(; serializer::Type = Serializer) = DiskCache{serializer}() + +""" + Cached.diskversion(f) -> String + +Version of the disk cache of `f`, part of its file name; `"1"` by default. Change it when the +stored results of `f` are no longer valid, or no longer readable (e.g. after a Julia update). +""" +diskversion(f) = "1" + +""" + Cached.disk_artifact(f) -> Union{Nothing, String} + +A directory with a read-only disk cache of `f`, made by [`export_disk_cache`](@ref), that is +consulted before the cache of the node, e.g. `artifact"results"`. Defaults to `nothing`. +""" +disk_artifact(f) = nothing + +const DISK_ENABLED = Threads.Atomic{Bool}(true) + +""" + disable_disk_caches!() + +Turn all disk caches off in this process, until [`enable_disk_caches!`](@ref). +""" +disable_disk_caches!() = (DISK_ENABLED[] = false; nothing) + +""" + enable_disk_caches!() + +Turn disk caches back on, after [`disable_disk_caches!`](@ref). +""" +enable_disk_caches!() = (DISK_ENABLED[] = true; nothing) + +# Called on a miss of the RAM cache (or on every call, with `NoCache` in RAM). +function _diskcall(f::F, disk::DiskCache, ::Type{V}, key, args, kw) where {F, V} + o = _ownerval(F) + (DISK_ENABLED[] && ccall(:jl_generating_output, Cint, ()) == 0) || return _compute(f, o, args, kw) + return disk_lookup(f, disk, V, key, args, kw, o) +end + +# The disk lookup, defined by the SQLite extension; without it, a call has a hint to load SQLite. +function disk_lookup end + +""" + disk_cache_info(f) -> Vector{Pair{Any, NamedTuple}} + disk_cache_info(m::Module) -> Vector{Pair{Any, NamedTuple}} + +The disk cache of `f` on this node, or those of the functions owned by `m` or its submodules +that have been used in this process, as `f => (; path, entries, bytes)` pairs. +""" +function disk_cache_info end + +""" + Cached.disk_cache_stats() -> Vector{Pair{Any, NamedTuple}} + +The disk lookups in this process of each function whose disk cache is open, as +`f => (; hits, misses)` pairs, where a hit is a result read from disk (from the artifact or +the database of the node) and a miss a result computed and written. Unlike +[`disk_cache_info`](@ref), it reads no files. +""" +function disk_cache_stats end + +""" + empty_disk_caches!(f) + empty_disk_caches!(m::Module) + +Remove the entries of the disk cache of `f` on this node, or of the caches listed by +[`disk_cache_info(m)`](@ref disk_cache_info). +""" +function empty_disk_caches! end + +""" + export_disk_cache(f, dir) -> String + +Write the disk cache of `f` on this node into `dir`, as a compact read-only database named as +[`Cached.disk_artifact`](@ref) expects it, and return its path. +""" +function export_disk_cache end diff --git a/src/preferences.jl b/src/preferences.jl index ca5c527..c7cc753 100644 --- a/src/preferences.jl +++ b/src/preferences.jl @@ -7,7 +7,7 @@ # The default container is a compile-time preference of Cached only, since it selects the # `CacheStyle` and so must be a constant. -const BUILTIN_SETTINGS = (; maxsize = 10_000, measure = "count") +const BUILTIN_SETTINGS = (; maxsize = 10_000, measure = "count", disk = true, disk_path = "") const SETTING_KEYS = map(string, keys(BUILTIN_SETTINGS)) """ Cached.cachesize(x) -> Integer @@ -44,6 +44,8 @@ function _resolve_settings(fname::AbstractString, cached::AbstractDict, package) return (; maxsize = settings["maxsize"]::Integer, by = MEASURES[settings["measure"]], + disk = settings["disk"]::Bool, + disk_path = String(settings["disk_path"]), ) end @@ -63,6 +65,7 @@ end _isvalid(k, v) = k == "measure" ? haskey(MEASURES, v) : k == "container" ? v in ("ClockCache", "LRU") : + k == "disk" ? v isa Bool : k == "disk_path" ? v isa AbstractString : v isa Integer && !(v isa Bool) && v >= 0 _cached_section() = Dict{String, Any}( @@ -96,7 +99,7 @@ _functioncaches!(f) = get!(() -> FunctionCaches(f), REGISTRY.functions, f) Store default cache settings in `LocalPreferences.toml`: globally (section `[Cached]`), for the functions of `package` (`[.Cached]`), or for the function `f` (`[.Cached.]`, where `` is the package that defines `f`). Settings are `maxsize`, `measure` -(`"count"` or `"bytes"`), and, globally only, `container` (`"ClockCache"` or `"LRU"`). A value of `nothing` removes the setting. +(`"count"` or `"bytes"`), `disk` and `disk_path` (see the disk caching docs), and, globally only, `container` (`"ClockCache"` or `"LRU"`). A value of `nothing` removes the setting. Settings apply to functions whose first cache is created afterwards, so in practice after a restart; changing `container` recompiles Cached. See the configuration docs for how settings @@ -104,8 +107,8 @@ combine. ```julia set_cache_preferences!(; maxsize = 100_000) -set_cache_preferences!(TensorKit; measure = "bytes", maxsize = 2^30) -set_cache_preferences!(TensorKit.fsbraid; maxsize = 50_000) +set_cache_preferences!(MyPackage; measure = "bytes", maxsize = 2^30) +set_cache_preferences!(MyPackage.expensive; maxsize = 50_000) ``` """ set_cache_preferences!(; settings...) = set_cache_preferences!(@__MODULE__; settings...) diff --git a/test/Project.toml b/test/Project.toml index 2d5162c..b06232a 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -3,6 +3,8 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Cached = "1b238080-9255-4fe9-b224-89eb24efe93b" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Preferences = "21216c6a-2e73-6563-6e65-726566657250" +SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" @@ -13,6 +15,8 @@ Cached = {path = ".."} [compat] Aqua = "0.8.9" ParallelTestRunner = "2.2.0" +SQLite = "1.6" +Serialization = "1" Tachikoma = "2.7" Test = "1.10" TimerOutputs = "0.5, 1" diff --git a/test/test_aqua.jl b/test/test_aqua.jl index 13352f0..907f230 100644 --- a/test/test_aqua.jl +++ b/test/test_aqua.jl @@ -8,7 +8,7 @@ end @static if VERSION >= v"1.11.0-DEV.469" @testset "public API" begin - for name in (:AbstractCache, :cache_stats, :cachesize, :implementation, :instrument_label) + for name in (:AbstractCache, :cache_stats, :cachesize, :implementation, :instrument_label, :diskversion, :disk_artifact) @test Base.ispublic(Cached, name) end end diff --git a/test/test_disk.jl b/test/test_disk.jl new file mode 100644 index 0000000..3720cf1 --- /dev/null +++ b/test/test_disk.jl @@ -0,0 +1,312 @@ +using Test +using Cached +using Preferences +using Serialization +using Serialization: AbstractSerializer +using SQLite: SQLite, DBInterface + +include(joinpath(pkgdir(Cached), "benchmark", "disk_stress.jl")) # `stress`, `use_disk_path` + +const EXT = Base.get_extension(Cached, :CachedSQLiteExt) +const DIR = mktempdir() # the disk caches of this file +const DIR_ENV = use_disk_path(DIR) + +allocs(f, x) = (f(x); @allocated f(x)) # in a function, as Julia 1.10 allocates at top level +ram_and_count(f) = (empty_caches!(); before = CALLS[]; r = f(); (r, CALLS[] - before)) +const CALLS = Ref(0) +path(f) = only(disk_cache_info(f)).second.path +entries(f) = only(disk_cache_info(f)).second.entries + +# a script in a fresh process, with this environment +function run_julia(code) + cmd = `$(Base.julia_cmd()) --startup-file=no -t1 --heap-size-hint=1G --project=$(Base.active_project()) -e $code` + return read(addenv(cmd, "JULIA_PKG_OFFLINE" => "true"), String) +end + +@cached square(x::Int) = (CALLS[] += 1; [x, x^2]) +Cached.DiskCacheStyle(::typeof(square), ::Int) = DiskCache() +plainsquare(x) = square(x) + +@testset "RAM, then disk, then compute" begin + @test Cached.DiskCacheStyle(square, 1) === DiskCache() === DiskCache{Serializer}() + @test Cached.DiskCacheStyle(sum, 1) === NoCache() + @test ram_and_count(() -> square(3)) == ([3, 9], 1) # computed, written to disk + @test square(3) == [3, 9] && CALLS[] == 1 # RAM hit + @test ram_and_count(() -> square(3)) == ([3, 9], 0) # disk hit + @test @inferred(square(3)) == [3, 9] + @test allocs(plainsquare, 3) == 0 # the RAM hit path is unchanged + info = only(disk_cache_info(square)) + @test info.first === square && info.second.entries == 1 && info.second.bytes > 0 + @test Dict(Cached.disk_cache_stats())[square] == (; hits = 1, misses = 1) # lookups on disk + @test endswith(EXT.storename(square), ".square-v1") # `.square-v1` + @test path(square) == joinpath(DIR, EXT.storename(square) * "-$(EXT._sanitize(gethostname())).sqlite") +end + +@cached ondisk(x::Int)::Vector{Int} = (CALLS[] += 1; [x]) +Cached.CacheStyle(::typeof(ondisk), ::Int) = NoCache() +Cached.DiskCacheStyle(::typeof(ondisk), ::Int) = DiskCache() + +@testset "NoCache in RAM: every call goes to disk" begin + n = CALLS[] + @test ondisk(1) == [1] && ondisk(1) == [1] && ondisk(2) == [2] + @test CALLS[] == n + 2 + @test isempty(cache_info(ondisk)) + @test @inferred(ondisk(1)) == [1] + @test entries(ondisk) == 2 +end + +struct Scale + a::Int +end +@cached (s::Scale)(x::Int) = (CALLS[] += 1; s.a * x) +Cached.DiskCacheStyle(::Scale, ::Int) = DiskCache() + +@testset "callable objects are part of the key" begin + @test ram_and_count(() -> (Scale(2)(3), Scale(3)(3))) == ((6, 9), 2) + @test ram_and_count(() -> (Scale(2)(3), Scale(3)(3))) == ((6, 9), 0) + @test entries(Scale(4)) == 2 # one store for the type +end + +# A serializer with its own format for `Point`: a string, readable in the database. +mutable struct PointSerializer{I <: IO} <: AbstractSerializer + io::I + counter::Int + table::IdDict{Any, Any} + pending_refs::Vector{Int} + version::Int + PointSerializer(io::I) where {I <: IO} = new{I}(io, 0, IdDict(), Int[], 0) +end +struct Point + x::Int + y::Int +end +function Serialization.serialize(s::PointSerializer, p::Point) + Serialization.writetag(s.io, Serialization.OBJECT_TAG) + serialize(s, Point) + return serialize(s, "point $(p.x) $(p.y)") +end +function Serialization.deserialize(s::PointSerializer, ::Type{Point}) + _, x, y = split(deserialize(s)::String) + return Point(parse(Int, x), parse(Int, y)) +end + +@cached point(x::Int) = (CALLS[] += 1; Point(x, 2x)) +Cached.DiskCacheStyle(::typeof(point), ::Int) = DiskCache(; serializer = PointSerializer) + +@testset "custom serializer" begin + @test Cached.DiskCacheStyle(point, 1) === DiskCache{PointSerializer}() + @test_throws ArgumentError DiskCache{Int}() + @test ram_and_count(() -> point(4)) == (Point(4, 8), 1) + @test ram_and_count(() -> point(4)) == (Point(4, 8), 0) + db = SQLite.DB(path(point)) + value = first(DBInterface.execute(db, "SELECT value FROM entries"))[1] + close(db) + @test occursin("point 4 8", String(copy(value))) +end + +const VERSION_TAG = Ref("1") +@cached versioned(x::Int) = (CALLS[] += 1; x) +Cached.DiskCacheStyle(::typeof(versioned), ::Int) = DiskCache() +Cached.diskversion(::typeof(versioned)) = VERSION_TAG[] + +@testset "a new version invalidates" begin + @test ram_and_count(() -> versioned(1)) == (1, 1) + old = path(versioned) + VERSION_TAG[] = "2" + EXT.close_all() # as a restart would + @test ram_and_count(() -> versioned(1)) == (1, 1) + @test ram_and_count(() -> versioned(1)) == (1, 0) + @test occursin("-v2-", path(versioned)) && isfile(old) # the old file is left alone + empty_disk_caches!(versioned) + @test entries(versioned) == 0 + @test ram_and_count(() -> versioned(1)) == (1, 1) +end + +@cached fragile(x::Int)::Vector{Int} = (CALLS[] += 1; [x]) +Cached.DiskCacheStyle(::typeof(fragile), ::Int) = DiskCache() + +@testset "unreadable entries are recomputed and overwritten" begin + @test ram_and_count(() -> fragile(1)) == ([1], 1) + db = SQLite.DB(path(fragile)) + for (column, bad) in ( + "value" => UInt8[0x00, 0xff, 0x13], # garbage + "value" => let io = IOBuffer() # another type than the value type + serialize(io, "a string") + take!(io) + end, + "key" => UInt8[0x01], # another key with the same hash + ) + SQLite.execute(db, "UPDATE entries SET $column = ?1", (bad,)) + @test ram_and_count(() -> fragile(1)) == ([1], 1) + @test ram_and_count(() -> fragile(1)) == ([1], 0) # the entry was overwritten + end + @test entries(fragile) == 1 + close(db) +end + +@cached switched(x::Int) = (CALLS[] += 1; x) +Cached.DiskCacheStyle(::typeof(switched), ::Int) = DiskCache() + +@testset "turning disk caches off" begin + disable_disk_caches!() + try + @test ram_and_count(() -> switched(1)) == (1, 1) + @test ram_and_count(() -> switched(1)) == (1, 1) + @test !any(p -> p.first === switched, disk_cache_info(Main)) # not opened + finally + enable_disk_caches!() + end + @test ram_and_count(() -> switched(1)) == (1, 1) + @test ram_and_count(() -> switched(1)) == (1, 0) +end + +@cached prefoff(x::Int) = (CALLS[] += 1; x) +@cached prefpath(x::Int) = x +@cached unwritable(x::Int) = (CALLS[] += 1; x) +Cached.DiskCacheStyle(::Union{typeof(prefoff), typeof(prefpath), typeof(unwritable)}, ::Int) = DiskCache() + +@testset "preferences, and disk errors" begin + package = Dict{String, Any}("disk_path" => "/x", "f" => Dict{String, Any}("disk" => false)) + @test Cached._resolve_settings("f", Dict{String, Any}("disk" => true), package)[(:disk, :disk_path)] == (; disk = false, disk_path = "/x") + @test (@test_logs (:warn, r"invalid preference `disk = 1`") Cached._resolve_settings("f", Dict{String, Any}("disk" => 1), nothing)).disk + @test_throws ArgumentError set_cache_preferences!(; disk = 1) + + # in a private project, as in `test_preferences.jl`; it comes before `DIR` on the load path + env = mktempdir() + write(joinpath(env, "Project.toml"), "[deps]\nCached = \"1b238080-9255-4fe9-b224-89eb24efe93b\"\n") + old = Base.ACTIVE_PROJECT[] + Base.ACTIVE_PROJECT[] = joinpath(env, "Project.toml") + other = mktempdir() + try + set_cache_preferences!(; disk_path = other) + @test dirname(path(prefpath)) == other + + set_cache_preferences!(; disk_path = joinpath(path(prefpath), "sub")) # under a file + @test_logs (:warn, r"cannot open the disk cache of `unwritable`") begin # once + @test ram_and_count(() -> unwritable(1)) == (1, 1) + @test ram_and_count(() -> unwritable(1)) == (1, 1) + end + + set_cache_preferences!(; disk = false) + @test ram_and_count(() -> prefoff(1)) == (1, 1) + @test ram_and_count(() -> prefoff(1)) == (1, 1) + @test isempty(disk_cache_info(prefoff)) + finally + set_cache_preferences!(; disk = nothing, disk_path = nothing) + Base.ACTIVE_PROJECT[] = old + end + @test all(startswith(basename(path(prefpath))), readdir(other)) # and its `-wal`, `-shm` +end + +@testset "default directory" begin + scratch = mktempdir() + Cached.Scratch.with_scratch_directory(scratch) do + @test EXT.diskdir(prefpath, "") == joinpath(scratch, string(Base.PkgId(Cached).uuid), "Main") + # packages get their own scratch space + @test EXT.diskdir(Preferences.load_preference, "") == joinpath(scratch, string(Base.PkgId(Preferences).uuid), "Cached") + end +end + +module Owned + using Cached + @cached a(x::Int) = x + @cached b(x::Int) = -x + Cached.DiskCacheStyle(::Union{typeof(a), typeof(b)}, ::Int) = DiskCache() +end + +@testset "management per module" begin + Owned.a(1), Owned.a(2), Owned.b(1) + info = disk_cache_info(Owned) + @test Set(first.(info)) == Set([Owned.a, Owned.b]) && sum(p -> p.second.entries, info) == 3 + @test issubset(info, disk_cache_info(Main)) + empty_disk_caches!(Owned) + @test all(p -> p.second.entries == 0, disk_cache_info(Owned)) +end + +const ARTIFACT = Ref{Union{Nothing, String}}(nothing) +@cached shipped(x::Int) = (CALLS[] += 1; x + 0.5) +Cached.DiskCacheStyle(::typeof(shipped), ::Int) = DiskCache() +Cached.disk_artifact(::typeof(shipped)) = ARTIFACT[] + +@testset "export to a read-only artifact" begin + @test ram_and_count(() -> (shipped(1), shipped(2))) == ((1.5, 2.5), 2) + artifact = mktempdir() + file = export_disk_cache(shipped, artifact) + @test basename(file) == EXT.storename(shipped) * ".sqlite" + @test_throws ArgumentError export_disk_cache(shipped, artifact) # exists + chmod(artifact, 0o555; recursive = true) # read-only, as artifacts are + + # an empty node store, with the artifact in front of it + empty_disk_caches!(shipped) + ARTIFACT[] = artifact + EXT.close_all() + @test ram_and_count(() -> (shipped(1), shipped(2))) == ((1.5, 2.5), 0) + @test ram_and_count(() -> shipped(3)) == (3.5, 1) # written to the node store + @test entries(shipped) == 1 + @test ram_and_count(() -> shipped(3)) == (3.5, 0) + @test readdir(artifact) == [basename(file)] # no journal or lock files + EXT.close_all() + chmod(artifact, 0o755; recursive = true) +end + +module Timed + using Cached + const LOG = Symbol[] + @cached f(x::Int) = x + Cached.DiskCacheStyle(::typeof(f), ::Int) = DiskCache() + function Cached.instrument(f, ::Val{phase}, thunk, ::Val{fullname(@__MODULE__)}) where {phase} + push!(LOG, phase) + return thunk() + end +end + +@testset "the :disk timing phase" begin + Timed.f(1) + @test Timed.LOG == [:lookup, :disk, :compute] + empty!(Timed.LOG), empty_caches!(Timed), Timed.f(1) + @test Timed.LOG == [:lookup, :disk] # a disk hit +end + +@testset "round trip through a fresh process" begin + dir = mktempdir() + code = """ + using Cached, SQLite + include($(repr(joinpath(pkgdir(Cached), "benchmark", "disk_stress.jl")))) + use_disk_path($(repr(dir))) + const calls = Ref(0) + @cached roundtrip(x::Int; scale = 1) = (calls[] += 1; (scale * x, string(x))) + Cached.DiskCacheStyle(::typeof(roundtrip), ::Int) = DiskCache() + print(roundtrip(1), roundtrip(2; scale = 3), " calls=", calls[]) + """ + @test run_julia(code) == "(1, \"1\")(6, \"2\") calls=2" + @test run_julia(code) == "(1, \"1\")(6, \"2\") calls=0" + # one file: the write-ahead log is merged into the database when the process exits + @test length(readdir(dir)) == 1 +end + +@testset "error hint without SQLite" begin + code = """ + using Cached + @cached nosqlite(x::Int) = x + Cached.DiskCacheStyle(::typeof(nosqlite), ::Int) = DiskCache() + try + nosqlite(1) + catch e + println(e isa MethodError, " ", sprint(showerror, e)) + end + """ + out = run_julia(code) + @test startswith(out, "true MethodError") + @test occursin("`DiskCache` needs SQLite.jl: run `using SQLite` first", out) +end + +@testset "processes sharing a database" begin + nkeys = 100 + r = stress(mktempdir(); nprocs = 4, nkeys) + @test r.bad == 0 && r.warnings == 0 + @test nkeys <= r.computed < 2nkeys # most values are computed once, but races are allowed + @test r.check == (; files = 1, bad = 0) +end + +EXT.close_all() +filter!(!=(DIR_ENV), LOAD_PATH) diff --git a/test/test_preferences.jl b/test/test_preferences.jl index 54eb7c7..33317ac 100644 --- a/test/test_preferences.jl +++ b/test/test_preferences.jl @@ -3,7 +3,7 @@ using Cached using Preferences using Aqua: Aqua -const resolve = Cached._resolve_settings +resolve(args...) = Cached._resolve_settings(args...)[(:maxsize, :by)] # the RAM settings caches(f) = last.(cache_info(f)) @testset "resolution order" begin @@ -109,7 +109,7 @@ with_private_preferences() do "measure" => "bytes", "test_all" => Dict("maxsize" => 4), "test_ambiguities" => Dict("measure" => "count") ) - @test Cached._resolve_settings("test_all", Dict(), section) == + @test resolve("test_all", Dict(), section) == (; maxsize = 4, by = Cached.cachesize) set_cache_preferences!(Aqua.test_all; maxsize = nothing) # empty sections are removed @test !haskey(load_preference(Aqua, "Cached"), "test_all") diff --git a/test/test_tachikoma.jl b/test/test_tachikoma.jl index 8219484..6f166fc 100644 --- a/test/test_tachikoma.jl +++ b/test/test_tachikoma.jl @@ -56,10 +56,12 @@ function draw(m; width = 120, height = 24) return tb end press(m, keys...) = foreach(k -> Tachikoma.update!(m, KeyEvent(k)), keys) -line(tb, text) = (p = find_text(tb, text); p === nothing ? "" : row_text(tb, p.y)) +# the first line below the tabs and the header (which shows the filter) containing `text` +line(tb, text) = (y = findfirst(y -> occursin(text, row_text(tb, y)), 3:tb.height); y === nothing ? "" : row_text(tb, y + 2)) # screen column of `text` (`find_text` gives a string index) column(tb, text) = (p = find_text(tb, text); textwidth(row_text(tb, p.y)[1:(p.x - 1)]) + 1) -select!(m, f) = (m.selected = findfirst(r -> r.f === f, m.lines); m) +lines(m) = Ext.current(m).lines +select!(m, f) = (t = Ext.current(m); t.selected = findfirst(r -> r.f === f, t.lines); m) function dashboard(filter) m = Ext.Dashboard(; interval = Inf, filter) Ext.refresh!(m) @@ -86,7 +88,7 @@ end @testset "one row per cache" begin m = dashboard("dash_") - square = only(r for r in m.lines if r.f === dash_square) + square = only(r for r in lines(m) if r.f === dash_square) @test (square.stats.hits, square.stats.misses, square.stats.length) == (2, 4, 4) @test square.label == repr(dash_square) @@ -94,17 +96,24 @@ end @test occursin("Clock", line(tb, repr(dash_square))) @test occursin("LRU", line(tb, repr(dash_lru))) # a function with caches of two container types has a row for each - two = filter(r -> r.f === dash_two, m.lines) + two = filter(r -> r.f === dash_two, lines(m)) @test length(two) == 2 && Set(r.kind for r in two) == Set(["LRU", "Clock"]) @test occursin(r"\d+B/977KiB", line(tb, repr(dash_bytes))) @test find_text(tb, "Activity") !== nothing && find_text(tb, "q quit") !== nothing + # the RAM tab is active, and its header sums all caches (also those hidden by the filter) + @test startswith(row_text(tb, 1), "[RAM] │ Disk") + rows = m.tabs[1].rows + entries = sum(r -> r.stats.length, rows) + bytes = Ext._short(sum(r -> r.stats.currentsize, filter(r -> r.bytes, rows)), true) + @test startswith(row_text(tb, 2), " $(length(rows)) caches · $entries entries · $bytes in byte-measured caches · recent hit rate ") + @test occursin("filter \"dash_\"", row_text(tb, 2)) # no recent lookups yet; the lifetime rate is in the detail panel @test occursin("░░░░░░-░░░░░░", line(tb, repr(dash_square))) select!(m, dash_square) @test find_text(draw(m; width = 160), "lifetime 33.3%") !== nothing # 2 hits, 4 misses # the name column is as wide as the longest name, with spare width at the right - longest = maximum(r -> textwidth(r.label), m.lines) + longest = maximum(r -> textwidth(r.label), lines(m)) @test column(tb, "Name") == 2 @test column(tb, "Hit rate") == 1 + 1 + max(longest, 20) + 1 @test column(tb, "Size") == column(tb, "Hit rate") + 14 @@ -119,14 +128,15 @@ end Ext.refresh!(m, m.lastrefresh + 1) @test occursin("50%", line(draw(m; width = 160), repr(dash_square))) press(m, 's', 'r') - @test first(Ext.SORTS[m.sortcol]) == "Hit rate" - @test findfirst(r -> r.f === dash_bytes, m.lines) < findfirst(r -> r.f === dash_square, m.lines) + t = Ext.current(m) + @test Ext.sorts(t)[t.sortcol].name == "Hit rate" + @test findfirst(r -> r.f === dash_bytes, lines(m)) < findfirst(r -> r.f === dash_square, lines(m)) press(m, 's', 's', 's', 'r') - @test m.sortcol == 1 && !m.reverse + @test t.sortcol == 1 && !t.reverse # filter through the input line press(m, '/', ntuple(_ -> :backspace, 10)..., 'l', 'r', 'u', :enter) - @test m.filter == "lru" && length(m.lines) == 1 + @test m.filter == "lru" && length(lines(m)) == 1 press(m, 'q') @test Tachikoma.should_quit(m) end @@ -151,9 +161,9 @@ end @test find_text(tb, "Size") === nothing @test find_text(draw(m; width = 60, height = 15), "Size") !== nothing @test find_text(draw(m; width = 60, height = 15), "Kind") === nothing - @test find_text(draw(m; width = 65, height = 15), "Kind") !== nothing - @test find_text(draw(m; width = 65, height = 15), "Activity") === nothing - @test find_text(draw(m; width = 80, height = 15), "Activity") !== nothing + @test find_text(draw(m; width = 69, height = 15), "Kind") !== nothing + @test find_text(draw(m; width = 69, height = 15), "Activity") === nothing + @test find_text(draw(m; width = 78, height = 15), "Activity") !== nothing @test find_text(draw(m; width = 80, height = 15), "hits/s") === nothing @test find_text(draw(m; width = 80, height = 16), "hits/s") !== nothing end @@ -163,17 +173,18 @@ foreach(f -> f(1), many) @testset "scrolling back when the terminal grows" begin m = dashboard("dash_many") - @test length(m.lines) == 7 - m.selected = length(m.lines) + @test length(lines(m)) == 7 + t = Ext.current(m) + t.selected = length(t.lines) draw(m; width = 60, height = 8) # scrolls down to show the selection - @test m.offset > 0 + @test t.offset > 0 draw(m; width = 60, height = 30) # everything fits again - @test m.offset == 0 + @test t.offset == 0 end @testset "empty and resize through key events" begin m = dashboard("dash_square") - c = only(m.lines).cache + c = only(lines(m)).cache press(m, 'e') @test isempty(c) @test occursin("dash_square", line(draw(m), "emptied")) @@ -198,7 +209,7 @@ end @testset "resizing a byte-measured function keeps its cache" begin m = dashboard("dash_bytes") - c = only(m.lines).cache + c = only(lines(m)).cache @test Cached.cache_stats(c).by !== nothing && length(c) == 1 press(m, :enter, :right, :enter) @test only(cache_info(dash_bytes)).second === c # not discarded @@ -208,20 +219,20 @@ end @testset "refresh picks up new caches and rates" begin m = Ext.Dashboard(; interval = 0.0, filter = "dash_late") draw(m) - @test isempty(m.lines) + @test isempty(lines(m)) dash_late(1) tb = draw(m) - @test length(m.lines) == 1 && find_text(tb, repr(dash_late)) !== nothing + @test length(lines(m)) == 1 && find_text(tb, repr(dash_late)) !== nothing Ext.refresh!(m, m.lastrefresh + 1) foreach(_ -> dash_late(1), 1:4) Ext.refresh!(m, m.lastrefresh + 2) - late = only(m.lines) + late = only(lines(m)) @test late.trend.rates[end] ≈ 2.0 && Ext.recent(late.trend) == 1.0 # caches that disappear are dropped, with their trends set_cache_size!(dash_late, 10; by = Cached.cachesize) # discards the cache Ext.refresh!(m) - @test isempty(m.lines) && !haskey(m.trends, late.cache) + @test isempty(lines(m)) && !haskey(m.trends, late.cache) @test draw(m) isa TestBackend end @@ -236,7 +247,7 @@ end foreach(_ -> dash_rate(1), 1:hits) foreach(_ -> dash_rate(misses[] += 1), 1:nmisses) Ext.refresh!(m, t += 1) - return only(m.lines) + return only(lines(m)) end rate(r) = Ext.recent(r.trend) Ext.refresh!(m, t) @@ -245,6 +256,7 @@ end end r = step!(0, 0) @test rate(r) == 1.0 && occursin("100%", line(draw(m), repr(dash_rate))) + @test Ext.recent(m.tabs[1].rows) == 1.0 # over all caches, from the summed lookups # a burst of misses shows up straight away, and fully within a window r = step!(0, 400) @@ -276,9 +288,160 @@ end m = dashboard("漢字") for width in (40, 160) tb = draw(m; width) - namewidth = clamp(textwidth(only(m.lines).label), 20, width - 1 - (width < 58 ? 14 : 14 + 23 + 6 + 9)) - row = row_text(tb, 3) # the only row; wide characters are followed by a padding cell + namewidth = clamp(textwidth(only(lines(m)).label), 20, width - 1 - (width < 58 ? 14 : 14 + 23 + 6 + 9)) + row = row_text(tb, 4) # the only row; wide characters are followed by a padding cell # the gauge starts right after the name column, as in the header @test textwidth(row[1:(findfirst('░', row) - 1)]) + 1 == column(tb, "Hit rate") == namewidth + 3 end end + +@testset "without SQLite" begin + # a fresh process with this environment, which has SQLite without loading it + code = """ + using Cached, Tachikoma + const Ext = Base.get_extension(Cached, :CachedTachikomaExt) + @cached nosql(x) = x + nosql(1) + m = Ext.Dashboard(; interval = Inf, filter = "nosql") + Ext.refresh!(m) + function draw(m) + tb = Tachikoma.TestBackend(100, 20) + Tachikoma.view(m, Tachikoma.Frame(tb.buf, Tachikoma.Rect(1, 1, 100, 20), Tachikoma.GraphicsRegion[], Tachikoma.PixelSnapshot[])) + return tb + end + tb = draw(m) + print(Base.get_extension(Cached, :CachedSQLiteExt) === nothing, " ", length(Ext.current(m).lines), " ") + print(Tachikoma.find_text(tb, "Clock") !== nothing, " ", Tachikoma.find_text(tb, "disk") === nothing, " ") + Tachikoma.update!(m, Tachikoma.KeyEvent(:tab)) + tb = draw(m) + print(Tachikoma.row_text(tb, 2), "|", strip(Tachikoma.row_text(tb, 3))) + """ + cmd = `$(Base.julia_cmd()) --startup-file=no -t1 --heap-size-hint=1G --project=$(Base.active_project()) -e $code` + out = read(addenv(cmd, "JULIA_PKG_OFFLINE" => "true"), String) + @test startswith(out, "true 1 true true ") + @test occursin("no disk caches: SQLite.jl is not loaded", out) && endswith(out, "|Load SQLite.jl (using SQLite) to use disk caches.") +end + +using SQLite: SQLite +include(joinpath(pkgdir(Cached), "benchmark", "disk_stress.jl")) # `use_disk_path` +const DISK_ENV = use_disk_path(mktempdir()) + +@cached dash_disk(x::Int) = x + 1 +Cached.DiskCacheStyle(::typeof(dash_disk), ::Int) = DiskCache() +@cached dash_diskonly(x::Int)::Int = 2x +Cached.CacheStyle(::typeof(dash_diskonly), ::Int) = NoCache() +Cached.DiskCacheStyle(::typeof(dash_diskonly), ::Int) = DiskCache() +@cached dash_disknotyet(x::Int) = x # its store is opened on the first call +Cached.DiskCacheStyle(::typeof(dash_disknotyet), ::Int) = DiskCache() + +# Draw until the entries and sizes on disk are read. +function settle!(m; kw...) + tb = draw(m; kw...) + while m.disktask !== nothing + wait(m.disktask) + tb = draw(m; kw...) + end + return tb +end + +@testset "disk tab" begin + # before any disk cache is used (as rows are only read on a refresh) + m = Ext.Dashboard(; interval = Inf, lastrefresh = time(), tab = 2) + @test find_text(draw(m), "No disk caches open yet") !== nothing && m.disktask === nothing + + dash_disk(1), dash_disk(2) + empty_caches!(dash_disk) + dash_disk(1) # two misses on disk, then a hit + foreach(dash_diskonly, (1, 1, 1, 2)) + stats = Dict(Cached.disk_cache_stats()) + @test stats[dash_disk] == (; hits = 1, misses = 2) && stats[dash_diskonly] == (; hits = 2, misses = 2) + @test !haskey(stats, dash_disknotyet) + + # the RAM tab has the caches in RAM only, marking those with a disk cache + m = dashboard("dash_disk") + @test only(lines(m)).f === dash_disk && only(lines(m)).kind == "Clock+disk" + tb = draw(m; width = 160) + @test occursin("Clock+disk", line(tb, repr(dash_disk))) && find_text(tb, repr(dash_diskonly)) === nothing + @test m.disktask === nothing # the disk is read only for the Disk tab + + # the Disk tab has a row per open disk cache, not `dash_disknotyet` + press(m, :tab) + @test m.tab == 2 && Set(r.f for r in lines(m)) == Set([dash_disk, dash_diskonly]) + rows = m.tabs[2].rows # also those of other test files sharing this process + hits, misses = sum(r -> r.stats.hits, rows), sum(r -> r.stats.misses, rows) + lookups = "$hits hits · $misses misses ($(Ext._percent(hits / (hits + misses))))" + # the entries and sizes show `…` until read in the background (collected by the next frame) + select!(m, dash_diskonly) + tb = draw(m) + @test startswith(row_text(tb, 1), " RAM │ [Disk]") && find_text(tb, "s sort") !== nothing && find_text(tb, "e empty") === nothing + @test startswith(row_text(tb, 2), " $(length(rows)) disk caches · … entries · … · $lookups · filter") + @test occursin(r"dash_diskonly +… +… ", line(tb, repr(dash_diskonly))) + @test occursin("… entries", row_text(tb, 20)) && occursin("2 hits · 2 misses in this process · hit rate: ", line(tb, "in this process")) + tb = settle!(m) + infos = [last(only(disk_cache_info(r.f))) for r in rows] + @test startswith(row_text(tb, 2), " $(length(rows)) disk caches · $(sum(i -> i.entries, infos)) entries · $(Ext._short(sum(i -> i.bytes, infos), true)) · $lookups") + @test occursin(r"dash_diskonly +2 +\d+(\.\d)?KiB ", line(tb, repr(dash_diskonly))) + info = last(only(disk_cache_info(dash_diskonly))) + @test occursin(r"^│2 entries · \d+(\.\d)?KiB · \S", row_text(tb, 20)) && endswith(info.path, ".sqlite") + + # read again at most every DISKINTERVAL seconds, on `g`, or for a new disk cache + draw(m) + @test m.disktask === nothing + press(m, 'g') + tb = draw(m) + @test m.disktask !== nothing && occursin(r"dash_diskonly +2 ", line(tb, repr(dash_diskonly))) # the last known values + settle!(m) + dash_disknotyet(1) + Ext.refresh!(m) + draw(m) + @test m.disktask !== nothing + @test occursin(r"dash_disknotyet +1 ", line(settle!(m), repr(dash_disknotyet))) + + # hit rate and activity are those of the lookups on disk + foreach(dash_diskonly, (1, 2, 1, 3)) + Ext.refresh!(m, m.lastrefresh + 1) + disk = only(r for r in lines(m) if r.f === dash_diskonly) + @test Ext.recent(disk.trend) == 0.75 && disk.trend.rates[end] == 3.0 + + # sorting is per tab: by entries here + press(m, 's', 'r', 'g') + @test Ext.sorts(m.tabs[2])[m.tabs[2].sortcol].name == "Entries" && m.tabs[1].sortcol == 1 + settle!(m) # sorted again when read + @test first(lines(m)).f === dash_diskonly # 3 entries, the most + + # the disk is neither emptied nor resized + select!(m, dash_diskonly) + press(m, 'e') + @test occursin("empty_disk_caches!", line(draw(m), "persistent")) && last(only(disk_cache_info(dash_diskonly))).entries == 3 + press(m, :enter) + @test m.pending === nothing + # on the RAM tab, emptying and resizing act on RAM + press(m, '1') + @test m.tab == 1 + select!(m, dash_disk) + press(m, 'e') + @test isempty(only(cache_info(dash_disk)).second) && last(only(disk_cache_info(dash_disk))).entries == 2 + @test occursin("its disk cache is kept", line(draw(m), "emptied")) + press(m, :enter) + @test occursin(repr(dash_disk), line(draw(m), "→")) + press(m, :escape) + + press(m, '2') + disable_disk_caches!() + try + @test occursin("· disk caches off", row_text(draw(m), 2)) + finally + enable_disk_caches!() + end + + # the smallest terminal shows the entries and sizes on the Disk tab, and the rows on both + tb = settle!(m; width = 40, height = 8) + @test find_text(tb, "too small") === nothing && find_text(tb, "Entries") !== nothing && find_text(tb, "Size") !== nothing + @test occursin(r"\d+ +\d+(\.\d)?KiB", row_text(tb, 5)) && find_text(tb, "in this process") === nothing + press(m, :tab) + tb = draw(m; width = 40, height = 8) + @test find_text(tb, "Hit rate") !== nothing && occursin("░", row_text(tb, 4)) +end + +Base.get_extension(Cached, :CachedSQLiteExt).close_all() +filter!(!=(DISK_ENV), LOAD_PATH)