Files
file-server/bin/bench_stage2.jl
Jeffrey Ward 4a22123001 Route by queue, not by directory: stop moving files between stages
A file is now written once into spool/ and stays there for its whole time in
flight. Stages 1 and 3 hand work on by enqueueing the same Job reference, so
job.path is constant from intake until commit. The three inter-stage renames
(spool->known, spool->unknown, unknown->text) are gone, along with the
known/, unknown/ and text/ directories and their FS_*_DIR settings.

Terminal moves stay: done/, text_done/, binary/ and failed/ still receive the
file, and binary/ in particular must, since it is the corpus the offline
stage-5 discovery sweep reads.

Measured by bin/bench_stage1.jl (2000 x 64 KiB, min of 5): the removed rename
cost 11.6 us per file, the equal of the classifier itself. One stage-1 worker
goes from ~28.6 us/file (~35k files/s) to 11.70 us (85.4k files/s); 16 workers
reach 333k files/s. What is left is classify 10.30 us, the queue handoff
0.12 us, and the disabled @debug lines 0.29 us.

The stage a file has reached now lives only in the queue holding its
reference, and the queues are in-process, so a crash loses it: everything in
spool/ replays from stage 1. That is safe rather than merely tolerable —
classification and the UTF-8 sniff are pure functions of the file's bytes and
the terminal commits rename with force=true, so a replayed file lands where it
would have landed and overwrites its own sidecar. The per-stage directories
were standing in for a durable queue, and charging every file a rename per
stage to do it; src/queue.jl already defines the seam where a broker-backed
JobQueue restores exact resume properly.

Recovery had to change to match. All leftovers now funnel onto the single
stage-1 queue, so the old non-blocking recover_dir! would have capped a
4000-file recovery at 1000 and abandoned the rest. It now blocks on a full
queue, and run() spawns the worker pools before recovering so the consumers
drain it as we fill; reset_metrics! moves above the spawn accordingly.
enqueue_blocking! takes stats::Union{StageStats,Nothing} so recovery reuses
the never-drop retry without charging its wait to a stage's blocked_ns, which
would drive /stats utilization negative.

Tests assert the new invariant positively (the file stays put, the routed
reference is unchanged, no intermediate directory appears) and cover recovery
of more files than the queue can hold. Verified end to end against a real
server: 40 leftovers, stage-1 capacity 3, all recovered and drained to
text_done/ with spool/ and failed/ empty.
2026-08-07 13:21:49 -04:00

701 lines
27 KiB
Julia

#!/usr/bin/env julia
#
# bench_stage2.jl — take stage 2 apart and find the slowest component.
#
# bin/bench.jl reports stage 2 as a single number (its throughput and worker
# utilization under whole-pipeline contention). It doesn't say *which part* of
# the stage costs the most, and stage 2 is the one stage whose cost is dominated
# by something outside Julia entirely: it forks `exiftool`, a Perl program, once
# per file. Per file the stage does:
#
# build_metadata
# run_exiftool fork/exec exiftool -json -G -n, capture stdout
# run_with_timeout the watchdog wrapper around the subprocess
# JSON3.read parse the dump
# normalize_metadata coalesce ~20 tag names into the sidecar schema
# finalize_known! (commit_enriched!)
# JSON3.write serialize the sidecar payload
# write + fsync durably persist the sidecar bytes to a temp name
# mv + fsync_dir commit the sidecar, then persist the rename itself
# move_to rename spool/<f> -> done/<f>, the commit point
# logging one @info line ("enriched")
#
# This script times each of those in isolation, then times the real
# `handle_known_job` end to end so the parts can be checked against the whole.
#
# Three things here that the stage-1 benchmark has no equivalent of:
#
# * The corpus must be real files. exiftool's cost depends on what it finds;
# random bytes exit early and would understate the stage by a lot. The
# default corpus is `data/done` — files that already went through stage 2 on
# this machine — copied back into a scratch spool/ dir.
# * Two rows price the *alternatives* to one-fork-per-file, because if the
# fork dominates then the only fixes are to stop paying it per file:
# `exiftool (batched Nx)` runs the whole corpus through one process, and
# `exiftool (-stay_open)` keeps a single process alive and feeds it one file
# at a time over a pipe — the shape a streaming pipeline could actually use.
# Both are measured, not assumed.
# * `run_with_timeout` gets its own row *next to* a bare `Base.run` of the same
# command. The difference is what the watchdog costs, and its polling loop
# (`sleep(0.1)`) is a suspicious enough design to want measured rather than
# reasoned about.
#
# The `--threads` sweep runs the full handler across worker counts: subprocess
# spawning contends on things (the kernel's fork path, page cache, the logger's
# stream) that a single-threaded ranking can't reveal.
#
# Usage:
# julia --project=. -t auto bin/bench_stage2.jl [options]
#
# --files N corpus files per timed pass (default: 48). The concurrency
# sweep wants more than the component rows do — with a single
# 2 s file in the corpus, 48 files can't show more than ~4x no
# matter how many workers run, so pass --files 150 when the
# question is scaling.
# --reps N calls per timed pass for cheap, non-consuming benchmarks (default: 2000)
# --trials N timed passes; the minimum is reported (default: 3)
# --corpus PATH directory of real files to draw the corpus from (default: data/done)
# --dir PATH working directory for the corpus (default: a temp dir under data/)
# --timeout SEC exiftool timeout, as Config.exiftool_timeout (default: 30)
# --threads LIST worker counts for the concurrency sweep (default: 1,2,4,8,nthreads)
# --no-threads skip the concurrency sweep
# --no-stay-open skip the persistent-exiftool probe
# --json PATH also write the results as JSON
#
# Reported times are the *minimum* over trials: the floor is the signal and
# everything above it is scheduler, page-cache and GC noise.
using FileServer
using JSON3
using Logging
using Printf
using Random
const FS = FileServer
# ---------------------------------------------------------------- option parsing
const DEFAULTS = Dict{String,Any}(
"files" => 48,
"reps" => 2000,
"trials" => 3,
"corpus" => "data/done",
"dir" => nothing,
"timeout" => 30,
"threads" => nothing,
"no-threads" => false,
"no-stay-open" => false,
"json" => nothing,
)
const FLAGS = ("no-threads", "no-stay-open")
const INTS = ("files", "reps", "trials", "timeout")
function parse_args(argv)
opts = copy(DEFAULTS)
i = 1
while i <= length(argv)
a = argv[i]
startswith(a, "--") || error("unexpected argument: $a")
key = a[3:end]
haskey(opts, key) || error("unknown option: $a")
if key in FLAGS
opts[key] = true; i += 1; continue
end
i + 1 <= length(argv) || error("option --$key needs a value")
opts[key] = key in INTS ? parse(Int, argv[i+1]) : argv[i+1]
i += 2
end
return opts
end
# ------------------------------------------------------------------- measurement
# Every timed loop stores its result here. Without a visible side effect the
# compiler is free to hoist a pure call out of the loop and we would be timing an
# empty `for`.
const SINK = Ref{Any}(nothing)
"""
best_of(pass, prepare; trials) -> ns_per_op
Run `pass()` `trials` times and report the fastest, in nanoseconds per operation
(`pass` returns the number of operations it performed). `prepare()` runs before
each pass and is *not* timed — that is where a consuming benchmark puts the file
back where it started. `pass` comes first so callers can pass it as a `do` block.
The first pass is thrown away: it pays Julia's JIT compilation, which on calls
this small is orders of magnitude more than the thing being measured.
"""
function best_of(pass, prepare; trials::Int)
best = Inf
for t in 0:trials
prepare()
GC.gc()
t0 = time_ns()
n = pass()
dt = Float64(time_ns() - t0)
t == 0 && continue # warm-up: compiled, not measured
best = min(best, dt / n)
end
return best
end
noop() = nothing
# ------------------------------------------------------------------- formatting
function human_time(ns::Real)
ns < 1_000 && return @sprintf("%.0f ns", ns)
ns < 1_000_000 && return @sprintf("%.2f µs", ns / 1e3)
ns < 1e9 && return @sprintf("%.2f ms", ns / 1e6)
return @sprintf("%.2f s", ns / 1e9)
end
function human_rate(r::Real)
r >= 1e6 && return @sprintf("%.2fM/s", r / 1e6)
r >= 1e3 && return @sprintf("%.1fk/s", r / 1e3)
return @sprintf("%.0f/s", r)
end
rate(ns::Real) = 1e9 / max(ns, 1e-9)
rule(n = 84) = println("-" ^ n)
function header(title)
println()
println(title)
rule()
end
# ------------------------------------------------------------------------- corpus
"""
make_corpus(cfg, corpus_dir, n) -> Vector{Job}
Copy up to `n` real files from `corpus_dir` into `spool/` and build the `Job`
references a stage-2 worker would dequeue for them — the exact input
`handle_known_job` sees.
Real files, not generated ones: exiftool's cost is a function of what it can
parse, and a file of random bytes bails out early enough to understate the stage
by an order of magnitude. `.meta.json` sidecars are skipped — they are stage-2
*output*, and enriching them would measure the wrong population.
"""
function make_corpus(cfg::FS.Config, corpus_dir::AbstractString, n::Int)
isdir(corpus_dir) || error("corpus dir not found: $corpus_dir")
names = filter(readdir(corpus_dir)) do f
!endswith(f, ".meta.json") && isfile(joinpath(corpus_dir, f))
end
isempty(names) && error("no usable files in corpus dir: $corpus_dir")
sort!(names) # deterministic selection across runs
length(names) > n && (names = names[1:n])
jobs = FS.Job[]
for (i, name) in enumerate(names)
src = joinpath(corpus_dir, name)
# Give it a fresh id/spool-style filename so nothing collides with the
# corpus the file came from.
id, spooled = FS.spool_path(cfg, @sprintf("s2-%04d-%s", i, basename(name)))
cp(src, spooled; force = true)
# Staged in spool/, not a known/ dir: stage 1 routes by enqueueing and
# leaves the bytes where intake put them, so this is the exact on-disk
# state a stage-2 worker dequeues into (src/worker.jl header).
push!(jobs, FS.Job(id, basename(name), spooled, filesize(spooled), time()))
end
return jobs
end
"""
respool!(cfg, jobs)
Put every corpus file back in `spool/`, wherever the last pass left it (done/ or
already home), and delete any sidecar it produced. This is the untimed `prepare`
step for benchmarks that consume their input by committing it — stage 2 still
moves, because its move is the terminal commit, not an inter-stage hop.
"""
function respool!(cfg::FS.Config, jobs::Vector{FS.Job})
for job in jobs
base = basename(job.path)
for dir in (cfg.done_dir, cfg.failed_dir)
sidecar = joinpath(dir, string(base, ".meta.json"))
rm(sidecar; force = true)
rm(string(sidecar, ".tmp"); force = true)
end
isfile(job.path) && continue
for dir in (cfg.done_dir, cfg.failed_dir)
candidate = joinpath(dir, base)
if isfile(candidate)
mv(candidate, job.path; force = true)
break
end
end
end
return nothing
end
# --------------------------------------------------------------------- loggers
"""
with_logger_named(f, which, path)
Run `f` under one of the loggers the cost of logging is bracketed by:
* `:null` — `NullLogger`: the `@info` macro's own overhead, nothing else.
* `:format` — `ConsoleLogger` to `devnull`: message formatting and key/value
interpolation, but no I/O.
* `:flush` — `FlushLogger(ConsoleLogger(io))` to a real file: what
`FileServer.run` installs, under the redirect it was written
for. Stage 2's per-file line is `@info`, not `@debug`, so this
row is what the deployed server actually pays.
"""
function with_logger_named(f, which::Symbol, path::AbstractString)
if which === :null
return with_logger(f, NullLogger())
elseif which === :format
return with_logger(f, ConsoleLogger(devnull))
elseif which === :flush
return open(path, "w") do io
with_logger(f, FS.FlushLogger(ConsoleLogger(io, Logging.Info)))
end
end
error("unknown logger: $which")
end
# --------------------------------------------------- exiftool spawn alternatives
"""
capture(cmd) -> Vector{UInt8}
Run `cmd` and return its stdout, tolerating a non-zero exit the way
`run_with_timeout` does. `read(cmd, String)` would throw instead, and a real
corpus makes that a question of when, not whether: exiftool exits 1 on a file
whose type it can't recognize, which in this pipeline is a routine outcome (it
yields a degraded sidecar, not a failure). This is `run_with_timeout` minus the
watchdog, so the gap between the two rows prices the watchdog exactly.
"""
function capture(cmd::Cmd)
out = IOBuffer()
proc = Base.run(pipeline(cmd; stdout = out, stderr = devnull); wait = false)
wait(proc)
return take!(out)
end
"""
batched_ns(paths, trials) -> ns_per_file
Run the whole corpus through *one* `exiftool` process and divide by the file
count. This is the floor for "what does exiftool cost if you stop paying the
interpreter startup per file" — the fork, the Perl boot and the module loads are
paid once for the batch instead of once per file.
"""
function batched_ns(paths::Vector{String}, trials::Int)
return best_of(noop; trials) do
SINK[] = capture(`exiftool -json -G -n $paths`)
length(paths)
end
end
"""
stay_open_ns(paths, trials) -> ns_per_file (or nothing if unsupported)
Feed files one at a time to a single long-lived `exiftool -stay_open True -@ -`
process over a pipe, reading its `{ready}` sentinel after each. Unlike the
batched row this preserves the pipeline's actual shape — one file in, one result
out, arriving whenever it arrives — so it prices the realistic fix rather than
an unrealistic one.
"""
function stay_open_ns(paths::Vector{String}, trials::Int)
inp, outp = Pipe(), Pipe()
proc = Base.run(pipeline(`exiftool -stay_open True -@ -`;
stdin = inp, stdout = outp, stderr = devnull); wait = false)
close(inp.out); close(outp.in)
ask(path) = begin
write(inp, "-json\n-G\n-n\n", path, "\n-execute\n")
flush(inp)
readuntil(outp, "{ready}")
end
try
ask(paths[1]) # pay the one-time process startup untimed
return best_of(noop; trials) do
for p in paths
SINK[] = ask(p)
end
length(paths)
end
finally
try
write(inp, "-stay_open\nFalse\n"); flush(inp); close(inp)
wait(proc)
catch
kill(proc, Base.SIGKILL)
end
end
end
# ------------------------------------------------------------------ components
"""
component_rows(cfg, jobs, opts) -> Vector
Time each piece of stage 2 on its own. The subprocess rows run once per corpus
file (they cost milliseconds and don't need repetition); the in-memory and
filesystem rows run `reps` times; the committing rows run once per corpus file
with an untimed reset between passes.
"""
function component_rows(cfg::FS.Config, jobs::Vector{FS.Job}, opts)
reps, trials = opts["reps"], opts["trials"]
timeout = opts["timeout"]
nfiles = length(jobs)
paths = String[j.path for j in jobs]
rows = []
add!(name, part, ns) = push!(rows, (; name, part, ns))
# --- the bare interpreter: fork/exec + Perl boot, reading no file at all.
# Everything the real call does beyond this is actual work.
add!("exiftool -ver (spawn)", "extract", best_of(noop; trials) do
for _ in 1:nfiles
SINK[] = capture(`exiftool -ver`)
end
nfiles
end)
# --- the real command, run directly: spawn + parse the file, no watchdog.
add!("exiftool -json (raw run)", "extract", best_of(noop; trials) do
@inbounds for p in paths
SINK[] = capture(`exiftool -json -G -n $p`)
end
nfiles
end)
# --- the same command through the watchdog wrapper the stage actually uses.
# The gap to the row above is what the timeout costs.
add!("run_with_timeout", "extract", best_of(noop; trials) do
@inbounds for p in paths
SINK[] = FS.run_with_timeout(`exiftool -json -G -n $p`, timeout)
end
nfiles
end)
# --- run_exiftool: the wrapper plus JSON3.read plus the group-stripped Dict.
add!("run_exiftool (total)", "extract", best_of(noop; trials) do
@inbounds for p in paths
SINK[] = FS.run_exiftool(p, timeout)
end
nfiles
end)
# --- what a fork-free exiftool would cost, two ways (see the docstrings).
add!("exiftool (batched $(nfiles)x)", "alt", batched_ns(paths, trials))
if !opts["no-stay-open"]
try
add!("exiftool (-stay_open)", "alt", stay_open_ns(paths, trials))
catch e
@warn "persistent-exiftool probe failed; skipping" exception = e
end
end
# --- parsing alone, from bytes already captured: isolates JSON3 from the fork.
raw = [FS.run_with_timeout(`exiftool -json -G -n $p`, timeout) for p in paths]
valid = [b for b in raw if b !== nothing]
if !isempty(valid)
add!("JSON3.read (parse dump)", "extract", best_of(noop; trials) do
@inbounds for i in 1:reps
SINK[] = JSON3.read(String(copy(valid[(i - 1) % length(valid) + 1])))
end
reps
end)
end
# --- normalize_metadata: the ~20 tag coalesces, on a tag map already in memory.
bytags = [FS.run_exiftool(p, timeout) for p in paths]
good = [(j, b) for (j, b) in zip(jobs, bytags) if b !== nothing]
isempty(good) && error("exiftool produced no parseable output for any corpus file")
add!("normalize_metadata", "extract", best_of(noop; trials) do
@inbounds for i in 1:reps
j, b = good[(i - 1) % length(good) + 1]
SINK[] = FS.normalize_metadata(j, b)
end
reps
end)
# The sidecar payloads, built once, untimed: the commit rows below measure
# committing, not extracting.
metas = [FS.normalize_metadata(j, b) for (j, b) in good]
# --- serializing the sidecar (the raw dump makes this bigger than it looks).
add!("JSON3.write (sidecar)", "commit", best_of(noop; trials) do
@inbounds for i in 1:reps
SINK[] = JSON3.write(metas[(i - 1) % length(metas) + 1])
end
reps
end)
# --- sidecar bytes: open + write + flush + fsync, to a temp name.
# Non-consuming: same path rewritten each rep, as commit_enriched! does.
tmp = joinpath(cfg.done_dir, "bench_stage2_sidecar.tmp")
blobs = [JSON3.write(m) for m in metas]
# One pass over the real sidecar population, not `reps` of them. Two reasons,
# and the first is a correctness trap: thousands of back-to-back fsyncs
# saturate the device's write cache and each one starts waiting on the
# queue, which reported this row at 16 ms/file — eight times the whole
# `commit_enriched!` that contains it. The real stage fsyncs once per file
# with ~160 ms of exiftool between, and never queues that way. Second, real
# sidecars vary hugely in size (a zip's raw dump dwarfs a jpeg's), so the
# honest per-file number is one pass over all of them, not a cycle.
nio = length(blobs)
add!("write + fsync (sidecar)", "commit", best_of(noop; trials) do
@inbounds for i in 1:nio
open(tmp, "w") do io
write(io, blobs[(i - 1) % length(blobs) + 1])
flush(io)
FS.fsync_fd(fd(io))
end
end
nio
end)
rm(tmp; force = true)
# --- fsync_dir: persisting the rename itself, once per file in the real path.
add!("fsync_dir (done/)", "commit", best_of(noop; trials) do
for _ in 1:nio
SINK[] = FS.fsync_dir(cfg.done_dir)
end
nio
end)
# --- move_to: the rename spool/<f> -> done/<f>. Consuming: reset each pass.
add!("move_to (rename)", "commit", best_of(() -> respool!(cfg, jobs); trials) do
@inbounds for job in jobs
SINK[] = FS.move_to(cfg.done_dir, job)
end
nfiles
end)
# --- commit_enriched!: the whole sidecar-first commit, extraction excluded.
committable = [j for (j, _) in good]
add!("commit_enriched! (total)", "commit",
best_of(() -> respool!(cfg, jobs); trials) do
@inbounds for (k, job) in enumerate(committable)
SINK[] = FS.commit_enriched!(cfg.done_dir, job, metas[k])
end
length(committable)
end)
respool!(cfg, jobs)
# --- the one @info line, under each logger.
job1, meta1 = good[1][1], metas[1]
logfile = joinpath(dirname(cfg.spool_dir), "bench_stage2.log")
for (which, label) in ((:null, "logging (NullLogger)"),
(:format, "logging (format only)"),
(:flush, "logging (flush→file)"))
ns = with_logger_named(which, logfile) do
best_of(noop; trials) do
for _ in 1:reps
@info "enriched" worker=1 id=job1.id dest=job1.path sidecar="x.meta.json" file_type=meta1.file_type created_by=meta1.created_by degraded=(meta1.error !== nothing)
end
reps
end
end
add!(label, "log", ns)
end
rm(logfile; force = true)
return rows
end
"""
handler_rows(cfg, jobs, opts) -> Vector
Time the real `handle_known_job` end to end under each logger. The difference
between the rows is the cost logging adds to a file; the `:flush` row is what the
running server actually pays.
"""
function handler_rows(cfg::FS.Config, jobs::Vector{FS.Job}, opts)
trials = opts["trials"]
nfiles = length(jobs)
logfile = joinpath(dirname(cfg.spool_dir), "bench_stage2.log")
rows = []
for (which, label) in ((:null, "handle_known_job (NullLogger)"),
(:format, "handle_known_job (format only)"),
(:flush, "handle_known_job (flush→file)"))
ns = with_logger_named(which, logfile) do
best_of(() -> respool!(cfg, jobs); trials) do
@inbounds for job in jobs
FS.handle_known_job(job, cfg, 1)
end
nfiles
end
end
push!(rows, (; name = label, part = "total", ns))
end
respool!(cfg, jobs)
rm(logfile; force = true)
return rows
end
"""
thread_rows(cfg, jobs, opts) -> Vector
Run the full handler across worker counts, under the server's real logger. Stage
2 spends most of its time in a child process, so this is the sweep that matters
most: whether the stage scales is a question about the kernel's fork path and the
machine's cores, not about Julia.
Workers pull from a shared atomic counter rather than taking a contiguous slice.
That matches the server (its pool pulls from one queue), and it matters here in a
way it doesn't for stage 1: per-file exiftool time spans two orders of magnitude
on a real corpus — a single 2 s archive among 48 files — so a static split leaves
whichever worker drew it running alone while the rest idle, and the sweep would
report a scaling ceiling that is really just load imbalance.
"""
function thread_rows(cfg::FS.Config, jobs::Vector{FS.Job}, opts)
trials = opts["trials"]
nfiles = length(jobs)
counts = opts["threads"] === nothing ?
unique([1; 2; 4; 8; Threads.nthreads()]) :
[parse(Int, s) for s in split(String(opts["threads"]), ",")]
counts = sort(unique(filter(k -> 1 <= k <= Threads.nthreads(), counts)))
logfile = joinpath(dirname(cfg.spool_dir), "bench_stage2.log")
rows = []
base = 0.0
for k in counts
ns = with_logger_named(:flush, logfile) do
next = Threads.Atomic{Int}(1)
best_of(() -> (respool!(cfg, jobs); next[] = 1); trials) do
# Shared counter, not a contiguous slice: every worker takes the
# next unclaimed file the moment it frees up, exactly as the
# server's pool takes the next job off the known queue.
@sync for t in 1:k
Threads.@spawn begin
while true
i = Threads.atomic_add!(next, 1)
i > nfiles && break
@inbounds FS.handle_known_job(jobs[i], cfg, t)
end
end
end
nfiles
end
end
r = rate(ns) # files/sec aggregate (ns is already per file, wall-clock)
k == counts[1] && (base = r)
push!(rows, (; workers = k, ns, files_per_sec = r, speedup = r / base))
end
respool!(cfg, jobs)
rm(logfile; force = true)
return rows
end
# ------------------------------------------------------------------- reporting
function print_components(rows, total_ns)
@printf("%-34s %-9s %12s %10s %9s\n", "component", "part", "per file", "rate", "% total")
rule()
for r in rows
@printf("%-34s %-9s %12s %10s %8.1f%%\n", r.name, r.part, human_time(r.ns),
human_rate(rate(r.ns)), 100 * r.ns / total_ns)
end
end
function print_threads(rows)
@printf("%-9s %12s %12s %9s\n", "workers", "per file", "throughput", "speedup")
rule()
for r in rows
@printf("%-9d %12s %12s %8.2fx\n", r.workers, human_time(r.ns),
human_rate(r.files_per_sec), r.speedup)
end
end
# ------------------------------------------------------------------------- main
function main(argv)
opts = parse_args(argv)
try
FS.assert_exiftool()
catch e
println(stderr, sprint(showerror, e)); return 1
end
root = opts["dir"] === nothing ?
mktempdir(pwd(); prefix = "bench_stage2_") : String(opts["dir"])
owned = opts["dir"] === nothing
cfg = FS.Config(
spool_dir = joinpath(root, "spool"),
done_dir = joinpath(root, "done"),
failed_dir = joinpath(root, "failed"),
exiftool_timeout = opts["timeout"],
)
for d in (cfg.spool_dir, cfg.done_dir, cfg.failed_dir)
mkpath(d)
end
jobs = try
make_corpus(cfg, String(opts["corpus"]), opts["files"])
catch e
owned && rm(root; recursive = true, force = true)
println(stderr, sprint(showerror, e)); return 1
end
bytes = sum(j.size for j in jobs)
println("stage-2 component benchmark")
rule()
@printf("%-22s %s\n", "julia threads", Threads.nthreads())
@printf("%-22s %s\n", "exiftool", strip(read(`exiftool -ver`, String)))
@printf("%-22s %s\n", "corpus", "$(length(jobs)) files ($(round(bytes / 1024^2; digits=1)) MiB) from $(opts["corpus"])")
@printf("%-22s %s\n", "scratch", root)
@printf("%-22s %s\n", "reps / trials", "$(opts["reps"]) / $(opts["trials"])")
@printf("%-22s %s\n", "exiftool timeout", "$(opts["timeout"]) s")
try
comps = component_rows(cfg, jobs, opts)
handlers = handler_rows(cfg, jobs, opts)
# The denominator is the handler as the server actually runs it: the real
# flushing logger, one worker. Percentages are shares of that, so they are
# directly comparable and the parts can be checked against the whole.
total = only(r.ns for r in handlers if r.name == "handle_known_job (flush→file)")
header("Components (single worker)")
print_components(comps, total)
header("Whole handler, by logger")
print_components(handlers, total)
pick(name) = only(r.ns for r in comps if r.name == name)
accounted = pick("run_exiftool (total)") + pick("commit_enriched! (total)") +
pick("logging (flush→file)")
println()
@printf("accounted: %s of %s (%.0f%%); unaccounted overhead %s\n",
human_time(accounted), human_time(total), 100 * accounted / total,
human_time(max(total - accounted, 0)))
threads = nothing
if !opts["no-threads"] && Threads.nthreads() > 1
threads = thread_rows(cfg, jobs, opts)
header("Full handler across workers (server logger)")
print_threads(threads)
end
if opts["json"] !== nothing
open(String(opts["json"]), "w") do io
JSON3.write(io, (;
julia_threads = Threads.nthreads(),
files = length(jobs), corpus_bytes = bytes,
reps = opts["reps"], trials = opts["trials"],
exiftool_timeout = opts["timeout"],
components = comps, handlers, threads,
))
end
println("\nwrote ", opts["json"])
end
finally
owned && rm(root; recursive = true, force = true)
end
return 0
end
exit(main(ARGS))