Skip to content

Record time as well as money for every non-free run (#143) - #147

Open
jonfroehlich wants to merge 6 commits into
mainfrom
feat/cost-and-time-accounting-143
Open

Record time as well as money for every non-free run (#143)#147
jonfroehlich wants to merge 6 commits into
mainfrom
feat/cost-and-time-accounting-143

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Closes #143 (except the Tillicum back-fill — see Blocked below).

We recorded money for paid APIs and nothing else. This adds the other three quadrants:
time for paid legs, time for free GPU legs, and money+time for cluster compute — and fixes
the defect the comment on #143 identified, which was actively losing data.

The bug that was live

DEFAULT_USAGE_LOG derived from REPO_ROOT, which in a linked worktree is the worktree. A
leg run from a scratch worktree wrote its ledger there and lost it when the worktree was
removed. That is how the #139 claude-opus-5 leg spent $70.41 and left no row.

#119's guard cannot see this, and the reason matters: it proves a log path was accepted,
never that the file it wrote survives. The operator followed the rule and lost the record
anyway, so promoting the rule into CLAUDE.md would not have caught it either.

Both ledgers now resolve through git rev-parse --git-common-dir, which every worktree of a
repo shares, so they all append to one canonical file in the main checkout. An explicit
--usage-log into a worktree warns; each logged leg prints the absolute path and the
running total; and it falls back to the local checkout when git can't answer (a tarball, an HF
clone) — bookkeeping must never be why a run refuses to start.

What else changed

Time is measured. There was no perf_counter anywhere in the harness. score_model now
fills a caller-owned timing dict (model-load / inference / panos actually called) and main
stops the clock in the finally, so a leg that dies partway still reports what it spent.
panos_called counts attempts — a call that raised burnt wall-clock and, on a paid provider,
still billed.

Free legs are no longer invisible. report_usage returned early whenever a detector had no
usage dict — i.e. OWLv2, Grounding DINO, Qwen, Molmo, YOLO, the entire GPU half of the
roster. They now write the same row with paid: false. Two legs deliberately write nothing: a
fully cached re-score, and --models rampnet, which replays committed detections rather than
running a model.

A compute ledger. scripts/analysis/slurm_usage.pyanalysis_out/compute_log.jsonl,
priced from a new verified-only COMPUTE_PRICING in pricing.py. Back-filled from a real
sacct pull: 3,991 allocations, 2,684.4 GPU-hours on klone since 2026-07-02
, with the raw
dump committed so it re-derives without a cluster account.

Reconciliation. vertex_usage.py --reconcile compares the committed ledger against Cloud
Monitoring per model. This is the only check that catches a silent no-write; on the real #139
numbers it reports claude-opus-5 MISSING with 11,988,993 input tokens unaccounted for.

The rule is now repo-level, in CLAUDE.md beside replication and record-in-GitHub: both
units, both cost types, ledgers committed into the main checkout.

Finding: sacct -D is worth 4.35x

96% of our klone allocations end in PREEMPTED (3,780 of 3,991), so the default sacct view —
last incarnation only — discards nearly everything:

#51 YOLO baseline rows GPU-hours
sacct -D 3,857 2,046.9
without -D 27 470.5

It validates the one figure the repo already had. docs/tillicum.md records 496.5
GPU-hours as of 2026-07-30; summing this ledger over jobs ending from 07-24, the running
total crosses 496.5 at 2026-07-29T21:17 — the evening before that line was written.

Blocked

Overlap

Touches the same section of docs/model_comparison.md as
data/claude-opus-5-nine-splits-139, in a different hunk (that branch narrates the incident;
this one documents the mechanism and the fix). Should merge clean.

Tests

39 new, 1,334 passing, still CPU-only and network-free. The reconciliation tests use the real
#139 numbers; the compute tests are checked against docs/tillicum.md's independently-derived
$4.20 / 4.67 GPU-h.

🤖 Generated with Claude Code (claude-opus-5[1m])

jonfroehlich and others added 5 commits August 19, 2026 06:16
…worktree (#143)

Three defects in the cost accounting, one of them actively losing data.

**Time was not measured at all.** No perf_counter anywhere in the comparison
harness; usage_log.jsonl carried a completion timestamp and no duration, so the
ledger could answer $/pano and never s/pano. The runtimes we have survive only as
prose in docs/model_comparison.md. score_model now fills in a caller-owned timing
dict -- model-load seconds, inference seconds, panos actually put through the
model -- and main stops the clock in the finally, so a leg that dies partway still
reports the time it spent. panos_called counts attempts, not successes: a call
that raised burnt wall-clock and, on a paid provider, still billed.

**Free legs left no record at all.** report_usage returned early when a detector
had no usage dict, which is every local GPU model -- OWLv2, Grounding DINO, Qwen,
Molmo, YOLO. Those are free in API terms and cost real GPU-hours, and they were
invisible to the one ledger we have. Every leg that spends something now writes a
row, with a `paid` flag and the host/GPU it ran on. Legs that spent nothing (a
fully cached re-score, a leg that never loaded) still write nothing, and the
`--models rampnet` arm is excluded outright: it replays committed detections, so
its row would be a zero appended to a committed file on nearly every run.

**The ledger could be written somewhere that does not outlive the run.**
DEFAULT_USAGE_LOG derived from REPO_ROOT, which in a linked worktree is the
worktree -- so a leg run from a scratch worktree wrote its ledger there and lost
it when the worktree was removed. That is how the #139 claude-opus-5 leg spent
$70.41 and left no row, recovered only because Cloud Monitoring still had it.
#119's guard cannot see this: it proves a log path was accepted, not that the file
survives. The default now resolves through `git rev-parse --git-common-dir`, which
every worktree shares, so they all append to one canonical ledger; an explicit
--usage-log pointing into a worktree warns; and each logged leg prints the
absolute path plus the running total, so a run that logged somewhere unexpected is
visible while someone is still watching rather than six weeks later when the
provider's telemetry has aged out.

Falls back to REPO_ROOT whenever git cannot answer (a tarball, an HF clone):
bookkeeping must never be the reason a run refuses to start.

9 new tests, 171 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

The API half of an experiment's cost has been recorded per run since #119. The
compute half was not recorded anywhere -- Tillicum's $0.90/GPU-hour and klone's
GPU-hours lived only as prose in docs/tillicum.md, written by hand, per job, when
someone remembered. Unlike API tokens this half is partly back-fillable from
`sacct`, which is the reason to build it now rather than later: the retention
window is finite.

scripts/analysis/slurm_usage.py parses `sacct` (live on a login node, or a saved
dump via --from-file for a machine that cannot reach the cluster) into
analysis_out/compute_log.jsonl. Verified against the two numbers docs/tillicum.md
already carries, which were arrived at independently of this code: the data-prep
job's 4.67 GPU-hours and $4.20 come back exactly.

Three things it gets right that a hand tally does not:

- **`-D`.** Slurm shows only the LAST incarnation of a requeued job by default,
  and our klone runs live on the preemptable ckpt partition -- the paper's Stage 2
  run was 15 preemptions, 44.7 h of compute across 74.6 h of calendar. Rows are
  therefore keyed on (cluster, job id, start), not the job id, or all but the last
  incarnation's compute disappears.
- **GPU-hours = elapsed x N GPUs**, which is how Tillicum bills: an idle GPU in a
  2-GPU job costs exactly as much as a busy one. The generic `gres/gpu=N` and the
  typed `gres/gpu:a40=N` are the same GPUs reported twice, so the generic count
  wins rather than being added.
- **Idempotent.** Re-running appends only what is new; a job first seen while
  RUNNING is re-appended once terminal, and readers take the last row per key.

pricing.py gains COMPUTE_PRICING under the same verified-only discipline as the
token table: rate, as-of date, source, and Slurm's per-QoS UsageFactor. Tillicum's
`debug` entry carries the unresolved conflict with it -- Slurm bills that QoS at
factor 0 while hyakusage charged the smoke job $0.03 anyway -- so nobody can quote
a bare "free" from it without the source. An unpriced cluster returns None rather
than $0, because "we checked and it is free" (klone) and "we have no rate" are
different statements and only one of them is safe to put in a paper.

canonical_repo_root and the JSONL read/append helpers move to rampnet/ledger.py so
both ledgers share them -- otherwise the new script would have reintroduced the
worktree defect it exists to help measure.

14 new tests; full suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ld (#143)

The standing rule lived in docs/model_comparison.md, which reads as a convention
of that one harness, and said nothing about time or about non-free compute. It is
now a repo-level rule beside the replication and record-in-GitHub ones: both units,
both cost types, recorded at run time, ledgers committed into the main checkout.

docs/model_comparison.md's cost section is rewritten to match what the code now
does -- the per-leg timing fields, the free-leg rows and the two legs that
deliberately write nothing, the fourth (compute) ledger, and a subsection on the
worktree defect that is precise about why the #119 guard could not catch it: it
proves a log path was accepted, never that the file survives. Promoting the rule
would not have caught it either, and saying so is the point.

docs/tillicum.md now says hyakusage is a live view rather than a record, and
points at the ledger. docs/replication.md lists compute_log.jsonl with its gap
stated: the sacct back-fill has not been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#143)

The comment on #143 is right that nothing in the plan caught the failure that
actually happened. #119's guard proves a log path was ACCEPTED; it says nothing
about whether the file survived, which is how the #139 leg billed $70.41 and left
no row. Layers 1 and 3 measure the same spend two ways and until now nothing
compared them.

`vertex_usage.py --reconcile` totals the committed ledger per model over the same
window as the metric query and prints them side by side. On the real #139 numbers
it reports claude-opus-5 as MISSING with 11,988,993 input tokens unaccounted for,
and says what to do about it: the metric retains ~6 weeks, recovery is per-model
per-DAY, so per-split attribution is gone even on a successful pull.

Deliberately asymmetric: billed > ledger is spend with no record and is called
out; ledger > billed is odd but harmless. Tolerance 2%, because Cloud Monitoring's
daily rows are 24 h windows ending at the query's time-of-day rather than calendar
days, so a leg straddling the boundary moves either way. Free legs carry no token
keys and are skipped -- they have no bill to reconcile against, and counting them
as zero-token models would invent a MISSING verdict.

The comparison functions are pure and tested against the real incident's numbers;
nothing in the new tests touches the network or needs cloud credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…worth 4.3x (#143)

3,991 job allocations since 2026-07-02, written from a real `sacct` pull while the
klone control master was up. The raw dump is committed alongside, so every number
in docs/compute_cost.md is re-derivable from a clean clone with no cluster account
-- the same reason usage_log.jsonl is committed while vertex_usage.py needs cloud
credentials.

**`sacct -D` is worth 4.35x on this workload, and that is the finding.** Slurm
reports only the last incarnation of a requeued job by default, and 96% of our
allocations end in PREEMPTED (3,780 of 3,991) because the work lives on ckpt. The
#51 YOLO baseline is 2,046.9 GPU-hours across 3,857 incarnations with -D, and
470.5 across 27 job ids without it. Any hand tally from a default sacct is short
by that factor and nothing in the output says so.

It validates the one number the repo already had rather than contradicting it.
docs/tillicum.md records 496.5 GPU-hours on the baseline as of 2026-07-30; summing
this ledger over jobs ENDING from 07-24, the running total crosses 496.5 at
2026-07-29T21:17 -- the evening before that figure was written. Reproduces to the
hour, and confirms the original query was duplicate-inclusive.

Two fixes found by running it for real:

- --save-raw did not create its parent directory, so the replication input it
  exists to write failed on a fresh path.
- Rows embedded the whole pricing entry, which at 3,991 rows was 1.7 MB of the
  2.6 MB file. They now carry the rate and its as-of date; the table with its
  caveats stays versioned in pricing.py.
- --cluster silently restamped rows whose Cluster column disagreed, which would
  price another cluster's jobs at the wrong rate and attribute their hours to the
  wrong machine. It now warns. (Nothing was mislabeled here: all 3,991 rows report
  klone, and the 456 H200 allocations are klone's own ckpt-g2 nodes.)

The ledger records every job on the account, not only RampNet's -- 28 sal-*/arch_*
allocations, 1.3% -- deliberately, so the artifact is a complete measurement and
attribution is the reader's call rather than a filter chosen once and baked in.

Gaps stated in the doc: Tillicum is NOT back-filled (Duo, master down), the window
starts where this account's retention does, and the paper's own runs were on
another user's account and are outside this query entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eight-split claude-opus-5 leg of 2026-08-18 spent real money and left no row,
so every cost table built from usage_log.jsonl under-reported the benchmark's
Claude spend by about 200x -- $0.34 against a true $70.44. Layer 3 recovered the
number the next day; until now it existed only as prose on the #139 branch.

The row is the billed total for that model-day MINUS what the two surviving
richmond smoke rows already account for: 11,940,249 in / 415,751 out / $70.0950.
Written that way the ledger sums to $70.4375 against a $70.44 bill, instead of
double counting the smoke legs. The subtraction, the billed totals it came from
and the recovery command are all in the row, so the arithmetic is checkable
without cloud access.

What made this worth more than an append is the direction the two requirements
pull. Cost totals must INCLUDE recovered spend or they are wrong by the whole
amount. Reconciliation must EXCLUDE it, because a recovered row was read off the
same bill it would be reconciled against -- counting it as "logged" makes the
bill agree with itself and reports ok for exactly the gap that check exists to
find. So ledger_totals_by_model now skips kind == "recovered" explicitly rather
than by the accident of which keys a row happens to carry, and ledger_totals
reports the recovered share separately so it never reads as an as-run number.

A recovered row carries no bundle and no elapsed_s. Recovery is per-model
per-day, so which of the eight splits spent what is permanently gone; that
limitation travels inside the row rather than in someone's memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record time as well as money for every non-free run: the harness times nothing, and the free-GPU half of the roster logs nothing at all

1 participant