perf: stream the threshold histogram instead of rescanning the score matrix (25 min -> 83 ms, bit-identical) - #2
Conversation
… score matrix The per-tag and per-category threshold searches were O(tags x thresholds x samples) when the problem is O(tags x samples). For a fixed threshold grid every confusion matrix is a suffix sum over a per-tag score histogram, so one pass answers all thresholds at once and the per-category search reduces to summing the per-tag histograms. StreamingThresholdHistogram folds each inference batch into a (num_thresholds + 1, tag_num) counter while it is still on the device, so test.py no longer materialises the (sample_num, tag_num) score and label matrices, and no longer all-gathers them to every rank -- only the counter is reduced. Measured on the real full EVA-Giant test run (295936 x 12476): threshold phase 24.86 min -> 82.5 ms, peak RSS 102.4 GB -> 28.1 GB, with output bit-identical across all 12476 tags and every category, on both CPU and CUDA. Runs unchanged on CPU-only machines: the accumulator follows the accelerator's device and skips the reduction when there is a single process. See #1 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reject histograms whose bin count does not match num_thresholds. The mismatch would not have raised on its own -- a 50-bin argmax indexes a 100-point grid without going out of bounds -- so it would have silently reported thresholds taken from the wrong grid. Also reject mismatched histogram pairs, non-2D batches, and an empty threshold grid, and make the float64 comparison path explicit so float64 inputs keep the precision the original numpy comparison had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review recordReviewed the branch and fixed what the review turned up; recording the verification here so the state is auditable. Issues found and fixed during review
Verification
I consider this ready to merge. |
Runs the threshold-search suite on CPU-only wheels, which is also what proves the CPU-only path keeps working: the CUDA parity tests skip and the remaining 58 must still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e maths without a GPU Two things this fixes, both found by asking whether a CPU-only run can actually decide correctness. Comparison dtype. The thresholds are float64 and the original scan wrote `sample >= th` in numpy, which promotes the float32 score to float64. Bucketizing against float32 bin edges rounds the threshold instead, which flips any score within one float32 ulp of one: float32(0.03) is 0.0299999993, below the real 0.03 threshold, yet not below its float32 rounding. Sweeping four float32 steps either side of all 100 thresholds, float32 edges disagree with the original on 50 of 800 scores while float64 edges disagree on none. Real sigmoid outputs never land there -- zero disagreements across 2.5e8 measured scores, which is why the full-scale comparison was bit-identical either way -- but the promotion removes the discrepancy outright and costs little: a-gpu 7.10s -> 8.92s, b-gpu 0.117 -> 0.125 ms/batch, b-cpu unchanged. Test coverage without a GPU. mcc/f1score/precision/recall had no tests at all despite feeding test_metrics.json, and were verified only by agreement with the old code, which cannot catch an error both share. They now have hand-worked expected values, with MCC additionally checked against the textbook formula, plus worked examples for the histogram, the threshold search and the tie-walk. The device-transfer branch in update() was unreachable on CPU, so it is gone: .to() of a tensor already on the target device is a no-op. Coverage of metrics.py on a CPU-only run is now 100%, and CI enforces it, so a line only a CUDA machine can reach fails the build rather than quietly going untested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran it on the real workdirThe earlier end-to-end checks used a copy of Published artifacts backed up first, overwritten by the run, then compared byte-for-byte:
|
Closes #1.
What this changes
The per-tag and per-category threshold searches were
O(tags x thresholds x samples)when the problem isO(tags x samples). For a fixed threshold grid, every threshold's confusion matrix is a suffix sum over a per-tag score histogram: withb(s) = #{k : thresholds[k] <= s}, a score counts as predicted-positive for exactlythresholds[0..b-1], sopredicted_positive[k]andtp[k]are suffix sums of the per-tag bin counts. One pass therefore answers all 100 thresholds, and the per-category search collapses to summing the per-tag histograms of that category, which today is recomputed from scratch.StreamingThresholdHistogramfolds each inference batch into a(num_thresholds + 1, tag_num)counter while the batch is still on the device.test.pyconsequently never builds the(sample_num, tag_num)score and label matrices, and the twoaccelerator.gathercalls that all-gathered 13.8 GiB of scores plus 13.8 GiB of labels to every rank become a reduction of two int64 counters, about 20 MB.build_threshold_histogramsis the offline entry point for callers that already hold the full matrices, and both searches accept ahistograms=argument so one pass feeds both.Measured effect
Full real EVA-Giant test run,
295936 x 12476, K=100, on 2x Xeon 6747P (192 logical cores) and B300. Full method, raw data and scripts in #1 and its linked gist.mainThe per-batch figure is the honest cost, since the histogram is built during inference. Against a measured 434 ms per batch of 64, that is 0.029% on CUDA and 0.41% on CPU, and proportionally far less when inference itself runs on CPU.
Correctness
Output is bit-identical to the current implementation, so no published threshold moves.
End-to-end, on the real workdir. I ran the full
animetimm.multilabel.teston this branch againstruns/eva_giant_560_dbv4itself — not a copy — with exactly the invocationtest_export_eva_giant_560.shuses (accelerate launch --num_processes 8 --mixed_precision bf16, batch 32, threshold 0.4,--force). The published artifacts were backed up first, overwritten by the run, then compared byte-for-byte:test_tags.csvtest_metrics.jsontest_options.jsontags.csvSo the rewrite leaves every published output unchanged, down to the byte. Recorded in the gist as
e2e_real_workdir.txt. An earlier run against a copy of the workdir additionally compared field by field — all 8 micro/macro metrics, all 3 per-category results, and all 12476 rows oftest_tags.csvacrossbest_threshold,best_f1,best_precision,best_recall, every one atabs_diff = 0— and is in the gist ase2e_comparison.txt.Unit tests.
test/multilabel/test_metrics.pytranscribes both original brute-force scans verbatim and asserts the new code reproduces them exactly, and separately checks a set of hand-worked examples whose expected values are derived in the comments — agreement with the old code alone cannot catch an error the old code shares. 83 tests, covering:1.0counts as positive, matching.to(int32).to(bool)mcc/f1score/precision/recall, which had no tests at all: hand-worked values, MCC against the textbook formula, degenerate counts, and the fact thatf1score'salphais beta squared whilecompute_optimal_thresholdssquares its owndf_tags, mismatched tag table, missing data, empty threshold grid, histograms built for a different gridCPU-only
Explicitly supported and tested. The accumulator takes its device from
accelerator.device, or from the first batch when none is given, so nothing needs a GPU.finalizeskips the reduction entirely whennum_processes == 1— there is a test that fails ifreduceis called in that case — and a test drives the whole path through a realAccelerator(cpu=True). The suite passes with CUDA hidden: 79 passed, 4 CUDA-only tests skipped. With a GPU present: 83 passed. CI runs the CPU-only configuration and enforces 100% line coverage ofmetrics.py, so a line that only a CUDA machine could reach fails the build rather than quietly going untested — correctness here stays decidable without a GPU.Notes
max_workersis kept on both public functions and ignored, so existing callers do not break; the searches no longer use a thread pool.pytest.iniandrequirements-test.txt, since the repository had no test setup..github/workflows/unittest.ymlruns the suite on CPU-only wheels (torch CPU, numpy, pandas, accelerate, pytest) with--cov-fail-under=100.sample >= thin numpy, which promotes the float32 score; bucketizing against float32 edges rounds the threshold instead and flips scores within one ulp of it. Over four float32 steps either side of every threshold, float32 edges disagree with the original on 50 of 800 scores and float64 edges on 0; over 249,520,000 real logits neither disagrees. The promotion costs about 25% of the CUDA histogram build and nothing measurable on the streaming path.