Skip to content

Milestone 5: the tail is published, and the throughput curve has an end - #12

Merged
skyoo2003 merged 16 commits into
mainfrom
m5-perf
Aug 18, 2026
Merged

Milestone 5: the tail is published, and the throughput curve has an end#12
skyoo2003 merged 16 commits into
mainfrom
m5-perf

Conversation

@skyoo2003

@skyoo2003 skyoo2003 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Pull Request

Summary

This project had never measured a latency distribution. Every published figure was a
mean over a sequential replay — 50 queries, one at a time — and a mean is exactly the
statistic a tail is invisible in. grep 'func Benchmark' returned nothing.

This is milestone 5. It brings an instrument (internal/loadgen, weft-eval bench),
a comparison quarantined in its own module (bench/), and the measurement design
written down before the numbers so the judgment rules could not be chosen after
them (docs/PERF.md).

Both clauses of the PRD's outcome sentence hold. weft's p99 at the registered load
point is 108.193 ms, of which stop-the-world accounts for 411 µs — 0.38%. bleve
v2.6.0 on the same machine, corpus, query set, arm and load generator is 57.525 ms,
so weft is 1.88× it against an order-of-magnitude bar.

Two things are not buried.

The plan's prediction was wrong twice, and the correction generalises. It predicted
the tail would be a working-set problem rather than a GC problem, against the PRD's own
risk row saying the opposite. Below saturation it is neither: STW is 0.062% of a rung,
GC CPU share 1.1%, and majflt is 0 across the whole warm ladder. The arithmetic error
is worth repeating because it is easy to repeat — milestone 3a's published
74,504-byte live heap was measured at rest, and during a query 30,549 candidates
are alive at once, so the GOGC target sits at 46–53 MiB rather than on its 4 MiB floor.
A heap figure measured at rest does not predict a collector under load.

And the ladder found something no pass line asked for: weft cannot sustain its own
sequential throughput, and bleve can.
At 27.28/s — exactly the rate its sequential
replay achieved — weft does not slow down, it collapses: p50 39.2 ms → 1.27 s, 1,438
of 10,000 requests shed, RSS 126 → 853 MiB. bleve at the corresponding rung of its own
ladder posts its best p99 of the sweep, sheds nothing, and sits at 59.3 MiB. bleve has a
knee too — p99 turns up to 83.468 ms at 314/s — but it sheds nothing and its RSS moves
8 MiB: a queue forming, not a memory collapse. The mechanism is live heap under
concurrency, which is Index.Doc decoding a whole record per candidate — the same
decode milestone 3b costed as a 1.4× tax on the working set, reappearing as the thing
that ends the throughput curve.

No engine code changed. pkg/fusion and pkg/scorer are both 0 lines.

Changes

  • internal/loadgen — an open-loop driver. Request i is due at
    start + i/rate whatever request i−1 is doing, and latency is measured from that
    due time. A closed loop lets a stalled server receive less load, so the stall appears
    as one slow request and the p99 that comes out is a p99 of a load the server chose.
    Exceeding the in-flight cap sheds and counts rather than blocking, because
    blocking would restore that bias at the load where it matters most.
  • Quantiles by nearest rank, absent when thin. An interpolated quantile is a
    latency no request experienced. A quantile needs 100 samples beyond it — p99 needs
    10,000, p99.9 needs 100,000 — or it is left out rather than printed with a caveat.
  • GC accounting, second design. The first classified samples as GC-hit or GC-free
    by the cycle counter; a smoke run produced 200 hits out of 200, because at 485
    collections over 200 queries every request overlaps one. Replaced by charging
    stop-the-world time per sample, with GC CPU share printed beside it for the mark
    assist that is not stop-the-world and would otherwise go unaccounted.
  • weft-eval bench — the ladder, plus page faults and context switches from
    getrusage, which is what separates a query waiting on a disk from one waiting on a
    core.
  • weft-eval bench -writes — what a commit costs the reads beside it, which is the
    question milestone 3b §4.3 left open. It copies the index first: a Commit
    against .eval-data/index would rewrite the corpus every published number names.
  • bench/, a separate module — bleve v2.6.0 and its ~20 transitive modules live
    there, so go list -m all at the root still prints one line and
    TestNoExternalDependencies still passes. Both harnesses share internal/loadgen
    through a replace directive: a comparison measured by two implementations of an
    open loop is not a comparison.
  • Five instrument defects, found by review and fixed before the published run.
    GCPause was charged over a shorter window than the Lat it is subtracted from —
    64% of the p99 elapsed before accounting began. -writes stamped its commit window
    after the Add loop, filing the writer's own lock contention under the baseline it
    is compared against. The unloaded median was 50 samples where the package's own rule
    wants 200, and it is the denominator of every arrival rate. GCPauseTotal allocated
    per call, producing ~150 MiB of garbage per ladder that the report then charged to
    the query. GC CPU share was a ratio of process-since-start totals rather than of
    per-rung differences. Every one biased toward a flattering number and none was
    visible in the output
    ; four ladders were discarded across the milestone. FINDINGS
    §4.1 tabulates before and after.
  • docs/PERF.md — the measurement design and both judgment rules, written before
    the numbers. It also registers what a favourable number would not mean.
  • docs/DECISIONS.md D-009, FINDINGS milestone 5, docs/testing/weft-m5.tdd.md,
    changie entries. (.claude/ is gitignored, so the PRD's milestone row, performance
    metric and GC risk row are updated locally and are not part of this diff.)
  • CImake bench-build. go build ./... does not descend into a nested module,
    so 427 lines carrying half of clause 2 had no gate at all. Neither bench target is
    in all or in CI: a shared runner's tail is a function of its neighbours.

Validation

  • go test -race ./... — all packages ok

  • golangci-lint run ./...0 issues

  • make depsgithub.com/skyoo2003/weft, one line, after bleve entered bench/

  • make arch — fusion sees no scorer, both API goldens unmoved

  • make spdx, make changelog-check, gofmt -l . — clean

  • (cd bench && go build ./... && go vet ./...), GOOS=windows go build ./...

  • git diff --stat main -- pkg/empty

  • Measured on Apple M4 / 16 GiB / macOS 26.5.2 / Go 1.26.1 / GOMAXPROCS 10, against the
    171,332-document evaluation index and the 50 judged TREC-COVID queries at k=10:

    rate (weft) 3.41/s 6.82/s 13.64/s 27.28/s 54.56/s
    p50 83.371 ms 51.515 ms 39.187 ms 1.2688 s 1.8816 s
    p99 108.193 ms 78.605 ms 64.709 ms
    shed 0 0 0 1,438 6,272
    peak RSS 120.7 MiB 123.2 MiB 126.3 MiB 853.0 MiB 921.7 MiB
    rate (bleve) 19.64/s 39.28/s 78.56/s 157.13/s 314.25/s
    p50 15.401 ms 11.745 ms 8.350 ms 7.797 ms 8.208 ms
    p99 57.525 ms 35.390 ms 25.597 ms 25.370 ms 83.468 ms
    shed 0 0 0 0 0
  • Write lock: lock held 11.063 s, of which the commit itself was 11.014 s and the
    20,000 Add calls 49 ms; worst read due inside the window 12.539 s, p50 outside
    83.401 ms. Published index verified intact afterwards.

Review Focus

  • internal/loadgen.Drive is where a measurement bias would hide. The open-loop
    schedule, the shed-rather-than-block path, and Sample.Due are what every number in
    this PR rests on.
  • The clock around the driver. Five defects in it were found by review and none by
    test, because a latency distribution looks equally plausible whether or not the
    instrument measuring it is honest. GCPauseTotal's reused buffer, the window
    GCPause is read over, and where -writes stamps its window are the three worth a
    second pair of eyes — and it is not obvious what test would have caught any of them,
    which is recorded as a gap rather than resolved.
  • benchWrites takes no do from its caller — deliberately. The ladder's closure
    is bound to the published index, and the arm must build its own against the copy;
    reads and the commit have to contend for one lock or the number means nothing.
  • bench/go.mod's replace. If bench is ever moved under pkg/ or cmd/, weft
    acquires ~20 external modules and a stated success metric becomes false. make deps
    is the mechanical check.

Risks / Notes

  • The saturation rule fires at rung 1 for both engines and that is a flaw in the
    rule, reported rather than fixed.
    p50 falls as load rises on both — a sparse open
    loop lets the machine cool between requests, a tight sequential loop does not — so
    the "unloaded" denominator is optimistic and the headline lands on the slowest rung.
    The rule was registered before the numbers, so it stands as it fired. What would fix
    it (an open-loop rung as the denominator) is named in FINDINGS §3.1 and left to
    whoever owns the rule. It is conservative for weft either way: its best measured p99
    is 64.709 ms, not the 108.193 ms quoted, and at the two engines’ best rungs the ratio is
    2.55× — worse than the headline’s 1.88×, so the rule is not flattering weft either.
  • One repetition of the corrected instrument, where docs/PERF.md requires the median
    of three.
    108.193 ms is a
    single observation whose run-to-run spread is unknown, so the 1.88× ratio has unknown
    error bars. It clears a 10× bar widely enough that no plausible spread reaches it,
    which is why the verdict stands and the caveat is recorded anyway — FINDINGS §4.5.
  • text+vector has no published tail. The deployable arm is 4× slower per query, so
    10,000 samples at 12.5% of its throughput is over five hours. The published p99 is
    for the arm bleve can be compared against, not the arm a user would run.
  • The comparison is not analyzer-matched. bleve's standard analyzer stems and
    drops stop words; engine.Tokenize does neither. The effects run in opposite
    directions and neither is worth 10×, which is all rule 2 asks — docs/PERF.md §4.
  • The hybrid arm is not compared at all. bleve's kNN needs a build tag and cgo
    faiss; taking that on changes the subject from "a Go search engine" to "a Go wrapper
    around faiss" — D-009.
  • A 20 MB bench/bench binary was committed and removed in this branch. It is
    gitignored now but remains in this branch's history; a rewrite is the only way out and
    is not worth one here.
  • A rung is silent for 51 minutes. bench prints nothing between the header and the
    end of a rung, which reads as a hung process and is not. A progress line would fix it
    and was deliberately not written between two measurements.
  • Follow-up, unscheduled: nothing bounds a commit's lock window. Two shapes are
    visible — train the partition outside the write lock (both argmax passes touch no
    shared state), or give Commit a context. FINDINGS milestone 5 §5.

…ne 5

RED: build fails on 8 undefined symbols in cmd/weft-eval (quantile,
printableQuantile, driveOpenLoop, benchSample, splitByGC, summarize,
saturationRate, headlineRate). The failure is the intended missing
implementation, not a syntax or setup error.

Journeys covered:
  J2 GC attribution     TestSplitByGCSeparatesTheTwoDistributions
  J3 thin quantiles     TestQuantileIsNotPrintedWhenTooThin
  J4 open loop          TestOpenLoopDoesNotLetTheServerSlowTheLoad
                        TestOpenLoopShedsRatherThanBlocks
  load-point rule       TestSaturationIsTheFirstRungPastTwiceTheUnloadedMedian

Plan: .claude/plans/weft-m5.plan.md tasks 2-3
GREEN: go test -race -run 'TestQuantile|TestOpenLoop|TestSplitByGC|
TestSummarize|TestSaturation' ./cmd/weft-eval/ passes, 15 subtests.

The design decisions the tests pin, and why each is not the obvious one:

  open loop        request i is due at start+i/rate whatever i-1 is doing,
                   and latency is measured from that due time. A closed
                   loop lets a stalled server receive less load, which is
                   coordinated omission: it reports one slow request where
                   an open loop reports every request the stall delayed.

  shed, not block  the in-flight cap is the only place the loop stops
                   sending, and exceeding it is counted rather than waited
                   on. Blocking would restore the closed-loop bias at the
                   exact load where it matters most.

  nearest rank     an interpolated quantile is a latency no request
                   experienced. Every printed figure is an observation.

  thin quantiles   a quantile needs 100 samples beyond it, so p99 needs
                   10,000 and p99.9 needs 100,000. Below that it is left
                   out rather than printed with a caveat.

  GC attribution   a sample is GC-hit when the cycle counter moved during
                   it -- not 'was slowed by a pause', which Go does not
                   expose. Counter read via runtime/metrics, not
                   ReadMemStats, which stops the world to answer.

Plan: .claude/plans/weft-m5.plan.md tasks 2-3
RED then GREEN: go test -race -run TestProcFaults ./cmd/weft-eval/
failed on undefined procFaults/procFaultCounts, now passes 2/2.
GOOS=windows go build ./... still builds through the !unix stub.

Latency alone cannot separate a query waiting on a disk from one
waiting on a core from one doing arithmetic, and the plan's section 1
predicts weft's tail is the first of those. getrusage is the only
instrument for it short of a profiler:

  minor   page the kernel had        -- page cache hit, ~hundreds of ns
  major   page the kernel fetched    -- storage, 4-5 orders of magnitude more
  nvcsw   the process yielded
  nivcsw  the scheduler took the core -- over-subscription from inside

The test that matters is the one asserting the counter MOVES after
touching 64 MiB of fresh pages. A stub returning a constant satisfies
every arithmetic test written about it, and a working-set verdict
resting on a number that never changes is the failure worth catching.

maxRSS and its two callers in recall.go are untouched.

Plan: .claude/plans/weft-m5.plan.md task 3
RED then GREEN: the first GC attribution design was measured and
discarded. Classifying a sample as GC-hit when the cycle counter moved
during it produced 200 hits out of 200 on a smoke run -- 485 collections
over 200 queries means every request overlaps one, so the classification
carries no information and the GC-free cohort was empty.

Replaced by charging: a stop-the-world pause stops every goroutine, so
the pause time inside a request's window is time it provably spent
stopped. p99(raw) and p99(raw - charged STW) are both printed and the
gap is the figure the milestone publishes.

The smoke run then exposed the second gap, and gcCPUShare closes it.
STW was 0.053% of the wall clock while the median query ran 66.9ms
against an unloaded 35.7ms. Mark assist is not stop-the-world and is
charged to the allocating goroutine -- the query -- so it appears in
neither figure. /cpu/classes/gc/total:cpu-seconds does report it.

Smoke, text arm, rate 4/s, n=200:
  latency   p50 66.908ms  max 99.706ms
  minus STW p50 66.777ms                 -> 131us of STW per query
  gc        cycles 495  STW 0.053%  GC CPU share 1.6%
  rusage    minflt 213  majflt 0  nivcsw 36761  maxrss 111.3 MiB

Nothing in pkg/ is touched. No optimization until the numbers are
published -- D-002 and the plan's Decisions table.

Plan: .claude/plans/weft-m5.plan.md tasks 2-3
Both harnesses have to use one driver. Assertion 3 of the plan is
'p99(weft) <= 10x p99(bleve) on the same machine, corpus, queries and
arm', and a comparison whose two halves are measured by two
implementations of an open loop is not a comparison -- coordinated
omission is exactly the kind of bias that would creep into one copy and
not the other.

bleve cannot live in the main module (PRD success metric: 'go list -m
all' prints this module and nothing else), so it goes in a bench/
submodule, and a submodule cannot import cmd/weft-eval's package main.
internal/loadgen is the shared home: still internal to
github.com/skyoo2003/weft by the lexical rule, still invisible to
go list -m all.

Moved, with names exported: Sample, Quantiles, Quantile, Printable,
Summarize, SplitByGC, Drive, SaturationRate, HeadlineRate, GCPauseTotal,
GCCPUShare, GCCycles, FaultCounts, ProcFaults, MaxRSS, Ladder,
DefaultInflight. rusage_unix.go and rusage_other.go moved with them, so
the Darwin-bytes / Linux-kilobytes trap has one implementation;
recall.go now calls loadgen.MaxRSS.

Behaviour unchanged. Same tests, same assertions:
  go build ./... && go vet ./...                          OK
  go test -race ./internal/loadgen/ ./cmd/weft-eval/      ok, ok

Plan: .claude/plans/weft-m5.plan.md task 5 preparation
Milestone 5's outcome sentence wants a comparison against an
established engine. The PRD's operational metric wants 'go list -m all'
to print one line. Both are real, and a submodule satisfies both: the
Go tool does not descend into nested modules for build, test or the
module graph, but gofmt and git ls-files still walk the tree.

Verified after adding bleve v2.6.0 and its ~20 transitive modules:

  make deps   -> github.com/skyoo2003/weft          (one line)
  make arch   -> ok  github.com/skyoo2003/weft/pkg/engine
  make spdx   -> OK: every .go file has an SPDX header

Both sides share internal/loadgen through a replace directive, so the
open loop, the quantiles and the GC accounting have one implementation.
A comparison measured by two drivers is not a comparison.

Scope, stated in bench/README.md rather than discovered later:
  - text arm only. bleve's kNN needs a build tag and cgo faiss, which
    would change the subject from 'a Go search engine' to 'a Go wrapper
    around faiss'. The arm a user would deploy is text+vector and this
    comparison cannot speak to it.
  - not analyzer-matched. bleve's standard analyzer stems and drops
    stop words; engine.Tokenize does neither. The two effects push in
    opposite directions and neither is worth an order of magnitude,
    which is the only claim being made.
  - same corpus bytes: title + " " + text, the same concatenation
    cmd/weft-eval indexes, and the same 50 judged queries at k=10.

First build: 171,332 documents in 17s, 150.6 MiB on disk.

Makefile: bench and bench-compare, both SKIP rather than FAIL without
their data -- same shape as recall. Neither is in all or in CI: a
shared runner's tail is a function of its neighbours, so a p99 gate
there is a coin flip.

Plan: .claude/plans/weft-m5.plan.md task 5
PERF.md is written before the numbers on purpose. It fixes two rules
that cannot be chosen afterwards:

  rule 1  the headline is quoted at the rung nearest half of
          saturation, where saturation is the first rung whose p50
          passes twice the unloaded p50. Every rung is published; the
          rule picks which one the summary quotes.
  rule 2  same order of magnitude means p99(weft) <= 10 x p99(bleve),
          same machine, corpus, queries, arm and driver.

It also registers what a favourable number would NOT mean: bleve
carries an analysis pipeline, stored fields and a facetable index weft
does not have. Writing that down before measuring is the only way it
survives a result that flatters us.

D-009 records the two structural choices and what each costs -- the
open loop (and why a closed one would invalidate the whole file), and
the bench/ submodule (and the hybrid arm it gives up).

The TDD index records 16 guarantees, the verbatim RED output for all
three cycles, and four gaps stated rather than hidden. The largest:
the -writes arm is unimplemented, because a Commit against
.eval-data/index would rewrite the corpus every published number is
measured against. Milestone 5 is not complete without it.

Plan: .claude/plans/weft-m5.plan.md task 6
Two of these invalidate numbers rather than annoy a linter, and the
ladder that was running when the review landed was killed for them.

GCPauseTotal allocated a fresh []metrics.Sample per call. metrics.Read
fills a Float64Histogram in place only when handed one of the right
shape; otherwise it allocates a 163-bucket one. At two calls per
request and 100,000 calls per ladder that is about 150 MiB of garbage
produced BY the pause counter, driving collections the report then
charges to the query -- and about 190ns added inside every latency
window, since the first read sits in the measurement. Reused buffer
under a mutex: 1,504 B / 3 allocs becomes 0 / 0, asserted with
testing.AllocsPerRun. This is the same objection the file already
raised against ReadMemStats; it applied to this function too and
nobody checked.

GC CPU share was cumulative, not per-rung. gcCPUStart was captured and
never read, so the published figure was the ratio of two
process-since-start totals -- by the fifth rung, a rung that gave a
third of its CPU to the collector and one that gave none print the
same number. The HEADLINE line quoted exactly this. Now GCCPUSeconds
at both ends and GCCPUShareBetween divides the differences: a ratio of
two differences, not the difference of two ratios.

Also, in rough order of how badly they would have bitten:

  - bench/bench, a 20 MB Mach-O arm64 binary, was committed by my own
    'git add -A' after a build. Removed and gitignored. Still in the
    history; a rewrite is the only way out and is not worth one here.
  - CI never compiled bench/. 'go build ./...' does not descend into a
    nested module, which bench/README.md says in as many words, so 427
    lines carrying half of milestone 5's clause 2 had no gate at all.
    make bench-build plus a ci.yml step.
  - Drive's cancellation check sat inside 'if time.Until(due) > 0', so
    a send loop behind schedule never observed it. The existing test
    used a no-op at 100/s and was always ahead of schedule, which is
    why it passed. Check moved to the top of the loop, test added for
    the behind-schedule state.
  - -rate took negatives and NaN. -rate=-5 walks every due time
    backwards, fires the whole rung at once, and prints latencies
    climbing into the thousands of seconds as a distribution. Rejected
    in both commands, with a backstop in Drive.
  - cold and unloaded failures only reached a counter. A misconfigured
    arm erroring in microseconds made unloaded the median of an error
    path and scaled the entire ladder from it -- ninety minutes of
    measuring nothing, with one WARNING at the end. Fail-fast before
    the ladder starts.
  - maxrss printed per rung and is a process-lifetime high-water mark.
    Relabelled peakrss (process), with PERF.md 2.5 saying why it cannot
    be a difference like the counters beside it.
  - bleve's qrels reader silently skipped malformed rows while weft's
    errors on them, so a truncated qrels file would have had the two
    sides replaying different query sets under a ratio that assumes one.
  - three golangci-lint failures that would have made CI red.
  - doc rot: an orphaned package comment godoc never showed, exported
    identifiers still described by their old lowercase names, the
    discarded GC-hit/free design still advertised in the package
    header, and one TDD row promising an invariant that had stopped
    being true.
  - Makefile: eval-full lost its comment block to the inserted bench
    target, and bench-compare hardcoded ../EVAL_DATA.

Skipped, deliberately: shed rungs bias p50 toward the requests that got
a slot, and SaturationRate does not read shed. Fixing it changes a
judgment rule PERF.md section 3 registered before the numbers existed,
so it belongs to whoever owns that rule, not to a review. Every rung
already prints shed.

Gate, run independently of the review's claims:
  gofmt -l .                        (empty)
  go build ./... and go vet ./...   OK
  go test -race ./...               all ok
  golangci-lint run ./...           0 issues
  make deps                         github.com/skyoo2003/weft
  make arch / make spdx             ok / OK
  cd bench and go build, go vet     OK
  GOOS=windows go build ./...       OK
Both clauses of the outcome sentence hold, and the prediction that got
there was wrong twice.

  weft   3.23/s   n=10000   p50 68.356  p95 87.912  p99 98.041ms
         minus charged STW            p99 97.762ms  -> GC is 0.28%
  bleve  19.53/s  n=10000   p50 13.686  p95 33.313  p99 47.123ms

Rule 2 wanted 10x and got 2.08x, same machine (Apple M4, Go 1.26.1),
same corpus, same 50 judged queries, same k, same driver.

The plan predicted the tail would be a working-set problem rather than
a GC problem, against the PRD's own risk row saying the opposite. Both
are wrong: STW is 0.047% of the rung, GC CPU share 1.0%, and majflt is
0 across the whole warm ladder. The arithmetic error generalises --
milestone 3a's published 74,504-byte live heap was measured at REST,
and during a query 30,549 candidates are alive at once, so the GOGC
target sits at 46-53 MiB rather than on its 4 MiB floor.

What the tail is instead: latency FALLS as load rises, monotonically,
over an eight-fold range on bleve (47.1 -> 24.8ms p99). A sparse open
loop leaves the machine idle between requests; a tight sequential loop
does not. So the 'unloaded' denominator is optimistic, the registered
saturation rule fires at rung 1 for both engines, and the headline
lands on the slowest rung of the ladder. The rule was fixed before the
numbers, so it is reported as it fired and not adjusted -- what would
fix it is named in FINDINGS 5 section 3 and left to whoever owns it.

Also recorded: the instrument was measuring itself until a review
caught it, and two ladders were discarded for it.

Not done, and milestone 5 is not complete without it: the -writes arm
that would price milestone 3b's 68-second write lock. A Commit against
.eval-data/index rewrites the published corpus, so it needs a copy.
Two smaller gaps named in FINDINGS 5 sections 4.4 and 4.5: no tail for
the text+vector arm, and one repetition where PERF.md requires three.
Rungs 2-5 landed and moved the verdict's centre of gravity.

  weft   3.23/s   p50 68.356ms  p99 98.041ms  shed 0      RSS 115 MiB
         6.46/s   p50 52.795ms  p99 78.237ms  shed 0      RSS 118 MiB
        12.93/s   p50 39.328ms  p99 81.070ms  shed 0      RSS 141 MiB
        25.86/s   p50  1.7995s  p99   --      shed 2363   RSS 817 MiB
        51.72/s   p50  2.1446s  p99   --      shed 6516   RSS 801 MiB

  bleve 19.53/s .. 156.23/s: p50 13.686 -> 7.248ms, shed 0 throughout,
        RSS 58.4 MiB at every single rung.

Two things, and the registered rule saw neither.

Latency falls as load rises on both engines, four-fold on weft and
eight-fold on bleve, because a sparse open loop lets the machine cool
between requests and a tight sequential loop does not. That makes the
'unloaded' denominator optimistic, fires the saturation rule at rung 1
for both, and puts the headline on the slowest rung. Reported as it
fired; what would fix it is named and left to whoever owns the rule.
It is conservative for weft either way -- its best measured p99 is
78.237ms, not the 98.041ms quoted.

And then: at 25.86/s, exactly the rate its own sequential replay
achieved, weft does not slow down, it collapses. p50 by a factor of
45, a quarter of the load shed, RSS from 141 to 817 MiB. bleve at the
corresponding rung of its own ladder is flat.

The mechanism is in the RSS column and it is not the collector's
baseline cost. A query holds 30,549 candidates and their decoded
records live at once; forty of those in flight is 800 MiB of LIVE
heap; GOGC targets a multiple of live heap, so collection work scales
with concurrency, which makes each query slower, which puts more in
flight. Milestone 3b costed Index.Doc's whole-record decode as a 1.4x
tax on the working set and registered a repayment trigger against page
counts. The trigger that actually fired is a different one: it is what
ends the throughput curve.

weft's usable throughput here is at least 12.93/s and under 25.86/s,
against a sequential 25.86/s. bleve's is at least its sequential 156/s.
Whether a lower in-flight cap moves the knee is unswept and said so.
The last pass line. Milestone 3b section 4.3 measured a 68-second IVF
training inside Commit, held under the write lock, and said the ceiling
is 'the one a load test will find'. This is that load test.

RED then GREEN on Sample.Due and SplitByWindow: samples come back in
completion order, so 'which requests were in flight while the lock was
held' is not answerable from the slice without the offset of each
request's scheduled send. The window is half-open, and that boundary is
a real decision -- a read due exactly when the commit began waited for
it, one due exactly when it ended did not.

It copies the index before touching it. A Commit against
.eval-data/index rewrites the corpus every number in docs/EVAL.md
names, and the provenance file exists precisely so nobody publishes a
figure from a different one. Verified after both runs: the published
index still holds one segment, one manifest, one provenance file.

The first answer was 36ms, and it is a finding rather than a small
number. Since milestone 3a a commit writes only what was added since
the last one, and a new segment below ivfMinDocs carries no partition
-- so a one-document commit skips the training entirely. The 68
seconds is not a property of Commit; it is a property of committing
171,332 documents at once, which is what a first build does and what
nothing else does.

Forcing the expensive case, 20,000 documents, twice:

  run 1   lock 11.641s   worst read due inside 13.142s   outside p50 69.075ms
  run 2   lock 10.881s   worst read due inside 12.414s   outside p50 70.034ms

A read arriving during a partition-training commit waits 190x the
median, and the 1.3-1.7s worst outside the window is the queue draining
afterwards -- the damage outstays the lock. Both scale with the size of
the commit, not the size of the index, so an ingest batching under the
partition floor never pays it and one committing a corpus pays in full.

The added documents carry synthetic vectors. Training cost is a
function of count and width, not content, and sourcing real vectors
inside a latency measurement means re-reading the corpus. Said in the
code and in FINDINGS rather than left to be discovered.

Two linter findings worth keeping: benchWrites took the ladder's `do`
and never used it, because the arm must build its own against the copy
-- a parameter that looked harmless there would have measured two
different indexes and reported the difference as the lock's cost.

Gate: gofmt clean, build, vet, go test -race ./... all ok,
golangci-lint 0 issues, make deps one line, make spdx OK,
bench/ builds and vets, GOOS=windows builds.
make changelog-check was red: the bench and -writes fragments were in
changes/unreleased/ and CHANGELOG.md had not been rendered from them.
The file is generated, so a hand edit is what the check exists to
catch -- this is the generator, run.
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: scorer area: ci labels Aug 18, 2026
@skyoo2003 skyoo2003 self-assigned this Aug 18, 2026
Three of these move published numbers, which is why they are one commit
rather than a cleanup pass. docs/FINDINGS.md milestone 5 still quotes
the pre-fix instrument; re-measuring is the next commit.

-writes stamped its window after the Add loop. engine.Add takes the
same exclusive ix.mu that Commit does, so at -writedocs 20000 the
writer blocked reads twenty thousand times before the window opened,
and SplitByWindow filed every one of those stalls under `outside` --
the baseline the commit's cost is compared against. The arm's whole
reason to exist is that comparison and the bias pointed at making the
lock look cheap. Window now opens before the first Add, and the log
line separates the writer's hold from the commit's part of it. The
misattributed span grew with -writedocs, which is the parameter the
experiment sweeps.

The commit probe was a bare goroutine polled through two atomic.Int64.
That could not tell "still running" from "never fired" from "failed",
and answered all three with a zero the report read as a window: a
commit outlasting the read load threw the run's samples away with an
error blaming the commit, while the goroutine ran on past the return
into the deferred wix.Close() -- which zeroes the index a Commit is
still writing. A failed commit was worse: the error was logged, `to`
was stamped anyway, and the lock cost of a commit that aborted at its
first check printed under the label of one that succeeded, exit status
0. Now a benchCommitWindow over a channel, joined before the window is
read, with err and fired as separate facts.

GCPause was charged over a shorter window than the Lat it is
subtracted from. Lat starts at Due; the pause total was read inside the
request's goroutine, so the window began only once the goroutine got a
P. A pause landing in the wait before that was in Lat and not in
GCPause -- and that wait is the queue, which is exactly where a
stop-the-world is most likely to land. Measured on this tree at 3000qps
inflight=32: the excluded prefix was p50 43.5us, p99 385.7us against a
total Lat of p50 148.5us, p99 602.6us, so 64% of the p99 elapsed before
pause accounting began. p99(exGC) under-subtracted most of the tail it
exists to explain. Read in the send loop now. What no clock read
recovers is Due-to-dispatch when the loop is behind schedule; that is
the coordinated-omission queue and it is documented as latency.

Also, in rough order of how badly they would have bitten:

  - an explicit -rate printed "saturation: R/s (first rung past 2x
    ...)" and "HEADLINE ... rate=R" for a one-rung ladder.
    SaturationRate over a single rate returns it whenever its p50 beats
    twice the unloaded median -- there is nothing for it to be *first*
    past -- and HeadlineRate returns it on both branches. So every
    single-rate run published the operator's chosen load point wearing
    the label of a measured one. Both lines suppressed below two rungs;
    the rung's own figures print instead.
  - the unloaded p50 was 50 samples, which Printable(50, 0.50) returns
    false for. The package's own rule wants 200 for a median, and this
    is not a footnote: it is the denominator of all five arrival rates
    and the reference SaturationRate compares every rung against, so a
    few ms of drift in it shifts the whole ladder and can move which
    rung is called saturated. 200 on both sides, through Summarize
    rather than a local sort, so the two denominators have one
    spelling.
  - -writes ignored -rate entirely. The flag parsed, validated, and
    then benchWrites overwrote it from the unloaded median: -rate 50
    against a 35ms p50 ran at 3.57/s. Only someone who noticed their
    own flag missing from the header would know.
  - -rate=Inf passed validation. ParseFloat accepts "Inf", Drive's
    `rate > 0` backstop admits it, and float64(time.Second)/+Inf
    truncates to a zero interval -- the whole rung due at the start,
    which is the burst the negative case produces reached by the
    opposite arithmetic. Anything above ~2e9 truncates the same way, so
    the ceiling is a real rate rather than only the non-finite ones.
  - -writes measured a fresh 626 MiB mmap with no warm pass of its own.
    benchWarmup's cold and warm passes ran against the published index;
    the copy's first-touch faults landed in the measured distribution
    and, since the commit fires a third of the way in, almost entirely
    in `outside`. One rotation over the copy first.
  - benchVecDim read Doc(0) alone. Add only checks a width when there
    is one, so ErrDimMismatch does not make every document carry a
    vector, and the evaluation corpus deliberately holds ones that do
    not -- the build logs how many it skipped for width. A vectorless
    document 0 reported width 0, the probe added text-only documents,
    and Commit skipped the IVF training -writedocs exists to reach: the
    arm priced the cheap commit under the expensive one's label.
    Bounded scan, with the engine.Index.VecDim accessor named as the
    real fix.
  - per-rung after-snapshots sat inside a composite literal, so Go's
    lexical evaluation put both Summarize calls -- four fresh
    10,000-element slices, ~260k comparisons -- ahead of the fault,
    cycle and pause reads. Those three charged the rung with the
    reporter's own page faults while gcCPU1, captured first, excluded
    them: one report whose GC CPU share and GC cycle count described
    different intervals. Hoisted, both sides.
  - buildIndex's defer ix.Close() persisted a partial index on any
    failure or Ctrl-C, and the os.Stat guard above then refused to
    rebuild it while measure opened it happily -- the guard producing
    the outcome its own comment says it prevents. RemoveAll on failure.
  - bench/ resolved qrels columns by position while internal/eval
    resolves them by header name, so a BEIR file with a different
    column order parsed on one side and errored on the other: two
    engines replaying different query sets under one ratio. bench/ now
    calls eval.ReadCorpus/ReadQueries/ReadQrels, which also brings the
    duplicate-id and empty-dataset refusals it had lost, at ~90 fewer
    lines.
  - Ctrl-C reported as measurement failure. -writes discarded every
    sample and blamed a condition that had not occurred, warmup said
    "the replay measured no time at all", and benchCold returned
    without printing -- throwing away the run's only pass whose major
    faults are real. All three return ctx.Err() or print what they
    reached.
  - TestGCCPUShareIsAFraction asserted share in [0,1], which 0
    satisfies. The /cpu/classes counters accumulate when a cycle ends
    and read 0 until one has, so the test passed over exactly the
    failure worth catching -- a metric name that stopped existing.
    Retargeted at GCCPUSeconds with a forced cycle; the caveat is on
    the function.
  - TestProcFaultsCountsMinorFaultsOnTouch strided 4096 bytes on a
    platform with 16 KiB pages, so its stated margin of 16,384 pages
    was 4,096. os.Getpagesize().
  - -arm was validated only in benchScorers, after the qrels hash and
    the whole index mapping. Checked at flag time, from one spelling of
    the accepted set.
  - rotations x queries could overflow into a negative make() capacity
    and panic after the index was mapped and replayed.
  - the rusage line printed four zeros and 0.0 MiB where getrusage does
    not exist, which reads as a measurement; the stub's comment already
    claimed it was omitted. Now it is.
  - dead GCCPUShare deleted, shed unlocked (only the send loop touches
    it), filepath.WalkDir, rangeValCopy, peakRSS captured at rung end,
    and comment rot from the procFaults/sub/gcPause renames.

Gate: bench/main.go was the one committed .go file no lint gate read --
golangci-lint run ./... does not descend into a nested module, and the
bench-build target added last commit closed the build half only. It was
failing this repository's own .golangci.yaml five ways. make lint and
ci.yml now run the linter in bench/ too; bench-build drops the go build
that left a 20 MiB binary nothing runs; make clean reaches bench/; the
CI cache is keyed on bench/go.sum.

Plan: .claude/plans/weft-m5.plan.md task 6
The instrument that produced them has been corrected in three ways that
move published figures, and the fix is in the tree while the numbers are
not. Leaving them unmarked would have an open PR presenting them as
measured by the code beside them.

Banner names all three and which sections each hits, including the
direction of the bias -- the -writes window fix and the GCPause window
fix both made the published figures look BETTER than they should have.
Also names what is not expected to move: neither ladder's shape nor the
collapse at 25.86/s depends on any of the three.

Re-measurement is running.
The banner comes off. Five instrument defects across two reviews, all
fixed, everything re-measured on one tree in one session.

  weft   3.41/s   n=10000  p50 83.371ms  p95 99.507ms  p99 108.193ms
         minus charged STW                             p99 107.782ms
  bleve 19.64/s   n=10000  p50 15.401ms  p95 36.863ms  p99  57.525ms

  ratio 1.88x against a 10x bar (was 2.08x on the broken instrument)
  collector's share of the p99: 411us, 0.38% (was 279us, 0.28%)
  write lock: 11.063s held, worst read caught in it 12.539s

What the corrections did to the numbers, in the direction they did it:
the GCPause window fix RAISED the collector's measured share, because
a stop-the-world landing in the queue before dispatch used to be in
Lat and not in GCPause. The 200-sample denominator moved every arrival
rate. Both push the headline up. FINDINGS 4.1 tabulates before/after.

The -writes window fix is the one whose magnitude surprised: Add takes
the same exclusive lock Commit does, so 20,000 of them really were
blocking reads outside the measured window -- and they cost 49ms of
11.063s, 0.4%. Right correction, small effect here, and both halves
now print rather than the total.

Two findings sharpened rather than changed:

Both ladders still fall as load rises and both now show where they
turn around. bleve's p99 bottoms at 25.370ms at 157/s and rises to
83.468ms at 314/s -- shedding nothing, RSS moving 8 MiB. That is a
queue forming. weft's turn is at 27.28/s and is not a queue: p50 39ms
to 1.27s, 1,438 of 10,000 shed, RSS 126 to 853 MiB. A memory collapse.
Naming the two knees as different kinds is what the re-measurement
added.

And the meta-finding, now its own section: five defects, every one
flattering, none visible in the output. A latency distribution looks
equally plausible whether or not the clock around it is honest. The
unit tests cover the driver's logic and nothing tests that, which is
recorded in the TDD report as a gap rather than resolved.

Gate: gofmt clean, build, vet, go test -race ./... all ok,
make changelog-check OK.
The Analyze job has failed on every run since 2026-08-18. The failure is not
in the analysis — CodeQL builds the database and evaluates the queries fine.
It is the last step:

  403 Code scanning is not enabled for this repository.

weft is a private repository on a plan without Code Security, and code
scanning results can only be uploaded where the feature is licensed. The
last successful upload was 2026-08-17 21:45 UTC on main; nothing in the
workflow or in the Go code changed between that run and the first failure.

So the workflow was doing three quarters of the work and throwing the result
away, at six minutes of runner time per pull request, while reporting the
discarded upload as a failed check. The comment at the top of the file said
"Findings are reported, not enforced" — but with nowhere to report them, that
sentence had stopped being true, and what was left was a red X on every pull
request that touches a .go file.

Removed rather than made non-blocking. A green check for an analysis whose
output goes nowhere is worse than no check: it claims a scan that nobody can
read. The file is one `git revert` away if the repository goes public or the
licence comes back.

No CHANGELOG entry: this moves nothing a caller can act on.
@skyoo2003
skyoo2003 merged commit ce1da46 into main Aug 18, 2026
4 checks passed
@skyoo2003
skyoo2003 deleted the m5-perf branch August 18, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci area: scorer documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant