Repository navigation
v1.12.0
Added
-
The mount and the gateway warn when
--mem-cachecannot fit on the box
(#314). A 20 GB tier on a 33 GB box was
accepted silently and the daemon was OOM-killed at RSS 30.56 GB. The check runs before
anything is fetched, because the failure it predicts is the process dying.The mechanism is not the one the issue was filed with, and the correction matters because
two of its three proposed fixes were built on it. #314 modelled the footprint as the tier
plus outstanding prefetch commitment (20 + 13.078 GB, summing to MemTotal almost
exactly). That addition does not hold.lith_prefetch_committed_bytesis charged at
dispatch and released only on consume/evict/fail, so it keeps counting a chunk after that
chunk has landed in the tier — it double-counts the resident half — and a dispatched
chunk that has not yet been admitted holds no chunk buffer at all, becausecloneChunkruns
afterfetchReaderreturns and after the bytes-in-flight budget is acquired. The field's
own comment inblockstore.gosays it: "it is NOT a memory figure".What does explain it is the collector. The tier is live Go heap (every chunk is a
make([]byte, cl)), lith sets neitherGOGCnorGOMEMLIMIT, and at the default
GOGC=100the heap target is about twice the live set. A 20 GB tier therefore targets
~40 GB on a 33 GB box and the process dies on the way there — which also explains why the
kill came at 30.56 GB rather than at the 33.08 GB the additive model predicted.So the ceiling is ~50 % of RAM, and it is arithmetic from
GOGC's definition rather
than a constant anyone picked — the distinction that cost three releases on
#340/#349.
2 × tieris a lower bound on the target (the tier is not the only live heap), so the
check under-warns rather than over-warns: a warning that fires on a configuration which
would have survived teaches operators to ignore it. The message names the ceiling and
GOMEMLIMIT, which makes the collector work harder instead of letting the kernel kill the
process.A warning and not a clamp or a refusal, consistent with
--prefetch-pressure-max: an
operator who typed a number keeps it, and a tier fills lazily, so a short-lived mount over a
small dataset may never reach the bound.Also documents why the default is 25 % — previously unwritten, and now load-bearing.
-
A warning when
--prefetch-budgetand--prefetch-pressure-maxcompose into a stall
(#313). Found while deriving the default
above, by writing out the denominator rather than the interesting term.The gate is sized against the tier, while the quantity it limits is independently bounded
by--prefetch-budget, which defaults to half the tier. That is the entire reason the
new default is safe: one handle's steady-state pressure is ~0.50 against a 0.85 threshold, so
the gate is silent until a concurrent start makes the divisor lag. Raise--prefetch-budget
to or above0.85 × --mem-cacheand a single handle at full budget sits at the threshold,
so the gate holds dispatches continuously and the mount throttles itself with nothing wrong —
andlith_prefetch_pressure_held_totalwould climb and look like the gate working. Two knobs
that are individually reasonable, composing into a self-inflicted stall that nothing would
have reported.The mount and the gateway now warn, naming the figure to lower.
BlockStore.MemCap()is
exported so the check uses the tier's realized capacity (shards × per-shard) rather than
the requested--mem-cache. -
--prefetch-sibling-coverage: concurrent readers of one object can establish again
(#316). Experimental, off by default.lith opens with
FOPEN_KEEP_CACHE, so every descriptor on an inode shares the kernel page
cache: a sibling's reads are served by the kernel and never reach lith, each handle's own
stream is punctate with coverage ≈ 1/N, and from four concurrent readers of one object
the #221 coverage gate holds every handle
provisional so nothing prefetches. Measured 243× slower than one reader alone with
byte amplification of 1.001 — the cleanest byte count of any cell and the slowest run,
so no byte or request counter can see it.A hole now counts as covered when another descriptor is open on the same object and the
bytes in it were already demanded through lith. Both conditions are load-bearing, and the
first was added after a test caught the fix being wrong: lith fetches a whole 1 MiB chunk to
serve a 128 KiB read, so for holes smaller than a chunk a lone strided reader's own
demand fetches make its own holes look demanded — which is
#222's shape and exactly what must not
establish. At chunk granularity the two cases are indistinguishable; what differs is who
demanded the bytes, and "someone else has this file open" is the cheapest sound proxy.The demanded condition holds the band between a chunk and a block, which nothing else
covers. Holes wider than--block-sizenever reach either condition — the byte-gap gate
forcesRandomfirst, which is also why a 16 MiB-stride fixture tests nothing here and the
first version of that test was vacuous for passing on the wrong gate.Confirmed externally at the default
--prefetch-coverage-min: 16 of 16 readers
establish in both reps, wall 67 s → 2.9 s,coverage_heldfrom ~1000 to 16 (the
same asO_DIRECT), with the holes themselves unchanged — it discounts them rather than
removing them. It is also the most byte-efficient of the three ways to get there: 0.67 GB
fetched for 0.54 GB read, against 1.89 GB for--prefetch-coverage-min 0.05and
0.86–1.53 GB forO_DIRECT."Already demanded" rather than "resident" is deliberate twice over: a random walk's holes
are not resident at all (#232 — no lever
there, and prefetching for one adds bytes without moving the wall clock), and lith's own
unread over-fetch is resident but undemanded, which must not count or a strided reader
would establish on regions its own window swept. -
Docs: the per-connection cold first-traffic cost, and the evidence gate's second job
(#381). A fourth rule in CONTRIBUTING's
measurement section, and the product consequence inknobs.md.A new connection's S3 first-byte time starts at ~100–150 ms and settles to ~30 ms over
roughly its first second of traffic — per-connection and ramp-like, not a setup cost: one
request per connection does not fix it, and about a second of traffic per connection does.
A client that opens many connections at once and measures immediately eats it in full: 22%
of first bytes under 50 ms against 92% after a one-second warm-up, so a cold first arm
reads 3–10× worse than steady state.The evidence gate's ramp turns out to protect against this, which nobody designed it for.
Measured on two mounts started at the same instant: with the gate off lith opens 83% of its
fills on new connections every rep and comes in up to 49 points colder, with identical
bytes fetched. The same cell explains the reused-connection queueing residual from #350 —
gate-off has zero (0.01 ms every rep) against 0.76–2.26 ms gate-on — so the ramp both
protects against the cold cost and causes the queueing. One mechanism, two signs.Recorded with the consequence and the tension: gate-off mounts (cross-region, or
region-unknown) open their connections at once and pay the cold cost on every fresh mount,
which trades against the measured 1.96× ramp penalty at distance. That net is unmeasured,
and the docs say so rather than implying a recommendation. -
lith-s3bench -warmup-workers: size the warm-up independently of the measured burst
(#381). The output line now reports
warmup=<dur>/<n>w, so an arm that warmed at a different scale than it measured says so.This is the variable the remaining #381 asymmetry turns on. A fresh lith mount is fast
while a freshlith-s3benchprocess at the same instant is slow — 17 of 18 simultaneous
pairs — and the structural difference is that a mount does region resolution and an index
read before its burst. The ladder established that a tiny separate request does not warm
the effect (head-objectand one worker for 0.2 s both left it present), so the open
question is how much traffic does, and answering it needs the warm-up and the
measurement sized independently within one process — which-warmupalone could not
express. -
lith-s3bench -warmup, and the measured window's offset from process start
(#381). A discarded window of the given
length before the measured one, in the same process, and astart+NNNmsfield in the output
so a cold first arm is self-identifying in a log.The first s3bench process of a run has been measured with a 3–10× worse first-byte tail in
7 of 7 runs, with the idle gap beforehand ranging from seconds to ~8 h — which rules out
a simply-cold endpoint. The decisive datum: a first s3bench process read endpoint 97.7 ms
while a lith mount on the same box, same key, same instant read 41.2 ms. That rules out
the endpoint, the network path, and box-wide state like DNS or the NIC, and leaves something
in or keyed to the process's first contact.So this is both the mitigation and a diagnostic: if an in-process warm-up removes the
effect, it is first-contact-in-process rather than anything run-level, and no amount of
warming from a separate process would have fixed it. Observed immediately: with a warm-up
theconn="new"arm disappears entirely and acquisition drops from 0.65–149 ms to 0.01 ms.The warm-up does not reset the request cursor, so the measured window reads different
ranges than the warm-up did and cannot be measuring a self-warmed object; and its wire
samples are discarded, which matters because folding them in would silently return the
effect being removed. -
lith-s3benchreports the same wire split as the mount, and labels its auth mode
(#350). ASPLITline per connection
class:acquire,write,endpointand theirwiresum, from the same
internal/s3clienttracer the mount uses.The comparison that issue has come down to is whether S3 answers a mount's requests slower
than an equivalent client's — and that cannot be settled while the two programs measure
through different code. Means rather than percentiles, because the question is which
interval carries the time and the three means must sum to the wire mean, which
percentiles do not.Auth is now in the output line, because an external comparison ran for several cells
with the mount signing and this tool anonymous — identical request fields, identical
ranges, identical SDK, different credentials — and nothing in either program's output said
so. It turned out to be worth only 6–21 points rather than the gap under investigation, but
it cost cells to establish that, and a confound visible in the output cannot persist
silently.-fresh-buffersself-labels for the same reason.
Changed
-
The prefetch pressure gate now defaults ON where the evidence gate is off
(#313).--prefetch-pressure-max 0— the
default — no longer means "disabled". It now decides from the evidence gate, which covers
exactly the complementary case:0.85where that gate is off (cross-region, or where the
bucket's region cannot be determined), and off where it is on. A negative value forces it
off, including on a mount where the default would engage.lith serve nfsengages it on
every export, because the gateway has no evidence gate at all.This is the keystone six other issues were sequenced behind, and the reason it is a default
rather than a mechanism is that the mechanism already shipped. The evidence gate bounds each
handle by its own consumed bytes, which is available from its first read and so has no
start transient. Where it is off, nothing did: N handles establish within milliseconds of
each other while the rationing divisor still reads 1, so each dispatches a full-budget
window. Since--prefetch-budgetdefaults to half the tier, 16 readers starting together
were measured at pressure 8.000 — N × 0.5 — against a steady state of 0.50.0.85rather than1.0, both of which are inside the measured band, and the reason is a
mechanism rather than a point between two observations. At1.0wall is 2.8× better than an
unbounded start (41 s vs 116 s) with 71–119 unread chunks still evicted; at0.85
evictions reach 0–4 for about 10% more wall. An evicted unread prefetch is a byte that
was fetched and discarded, and this default engages only where the evidence gate does not —
cross-region, where those bytes are billed egress. So the trade is ~10% wall against ~100
discarded-byte events, on exactly the mounts where a discarded byte has a price. In-region,
where wall clock would be the only term, the gate stays off.What it does not do, said before it ships: it bounds bytes, not fairness. Reader spread
stayed 2.3–3.1 at every threshold tested, against ~1.05 with the evidence gate on. It limits
the collapse; the fairness half of the gate-off case is still open.The two gates are decided from one input:
pressureMaxForreads
EvidenceRatioFor's result rather than re-deciding from the region pair. Two policies over
the same two booleans are two policies that can drift, and the failure would be silent — both
gates off, or both on, with nothing in the log looking wrong. A test asserts over the real
policy function that exactly one gate is in force at stock defaults, for every region
pair. It also makes the composition right for cases no region pair describes: forcing
--readahead-evidence-ratio 4cross-region stands this gate down, and forcing-1in-region
engages it.lith serve nfspasses a literal0because that is the truth and not a placeholder:
internal/nfscarries its own per-path detector and never calls intointernal/prefetch, so
no window in that path is bounded by what a reader has consumed at any region pair. The
gateway therefore inherits the band by the arithmetic — same blockstore, same
committed-bytes counter, same tier — rather than by its own measurement, which is
#337's cell. Said out loud because a figure
measured on one population and applied to another is how two bad defaults already shipped
here.Both commands now log
prefetch admissionwith both gates' resolved values, so which one is
in force is greppable rather than inferred, andlith serve nfsgained the
--prefetch-pressure-maxsanity warning the mount already had.
Fixed
-
lith serve nfsnever detected the NIC, so a stock gateway ran with byte gating disabled
(#393). Found while reading the resolution
chain for #317. The gateway set the NIC rate only from an explicit--nic-gbps, derived the
in-flight budget not at all, and passed no first-byte-latency seed — three missing
derivations, where the mount has all three:stock lith mountstock lith serve nfs(before)NICBytesPerSecdetected 0 → coalesce gap pinned to its 256 KiB floor instead of the device figure (~1 MiB at a 25 Gbps baseline) InflightBytes2 × NIC × 100 ms, else a 512 MiB fallback0, which blockstore.Configdocuments as "0 disables byte gating"TTFBseed40 ms unset → MeasuredTTFBreturns the seed until the first fill lands, so the gap floored on every cold prefix regardlessA mount cannot reach any of those states:
computeInflightBytesnever returns 0, by
construction and byTestFallbackNeverZero.TestServeMountFlagParitypassed throughout, and that is the more useful finding. It
asserts every mount flag is either present onserve nfsor documented inapplicable —
--nic-gbpsand--inflight-byteswere both present. The flags existed and nothing
derived from them. That is the second time the gate which should have caught a bug was
checking the adjacent property, and the sixth instance of this project's most repeated shape:
a second consumer of the same machinery with its own wiring, tests covering only the first
(#240, #242, #346, #239, #393).So the gate is now derivation parity, not flag parity:
TestServeMountBlockStoreConfigParity
walks both commands' ASTs and requires everyblockstore.Configfield the mount sets to be
set by the gateway too, or documented with a reason. It would have caught all three missing
derivations, and the exception map is currently empty. A gateway left at a field's zero
value is not the same as a mount at its derived default.The gateway also gains the mount's two NIC diagnostics, which it should have had: the
per-source failure reasons (#317) and the peak-vs-baseline warning (#239). An export is
more exposed than a mount to a wrong NIC figure — every client's bytes cross the one link,
so a figure 12× low or 2× high is multiplied across all of them.This is a restricting change for existing gateways: an export that previously had no
bytes-in-flight bound now acquires a NIC-derived one. That is the intended design and matches
every mount, but a gateway tuned around the unbounded behaviour could slow down; pass
--inflight-bytesexplicitly to keep a specific figure.docs/serving-a-cluster.mdsays so. -
A malformed
--inflight-byteswas silently discarded bylith mount
(#393). Found while unifying the two
commands' derivation.serve nfsparsed the flag and returned the error; the mount only
passed the string tocomputeInflightBytes, which falls through on a parse failure — so
an unusable value was accepted, replaced by the derived default, and the mount logged that
derived budget with nothing saying the flag had been ignored. The #264 class: a flag that
looks set and is not. Both commands now validate, and the two failure modes are reported
distinctly — an unparseable value is not blamed on positivity.(
parseSizeis deliberately lenient about case and whitespace, so512 MiBand512mib
are valid; a test asserting otherwise was wrong about the contract and was corrected rather
than the parser.) -
The peak-vs-baseline
--nic-gbpswarning now fires on the mount, where the mistake is
made (#239). It existed only in
lith doctor, which is opt-in, while the error is committed on the mount command line. A
tester passed--nic-gbps 15for ac7g.4xlargewhose sustained baseline is7.5— the
15is the "Up to 15 Gigabit" peak from the instance page — and paid 654 MB fetched
against 382 MB for the same 110 MB of data, with an indistinguishable wall clock: 42 % more
bytes for zero milliseconds. Nothing on the path they used said so.Same one-consumer gap as #240,
#242,
#346 and
#393: a second consumer of the same
machinery with its own wiring. The decision is now one function both commands call, so they
cannot drift, and it fires only when--nic-gbpsis set — an ordinary mount pays nothing.It also stops warning against figures lith did not measure, which it previously did. The
old guard compared against any source other thanfallback, which includedimds-estimate
— and #317 established that the size-keyed estimate reads 50 Gbps on ac8gn.48xlargewhose
real figure is 600. "Your--nic-gbps 600is well above the detected baseline of 50" would
have had the operator right and lith wrong. The comparison is now restricted toethtool,
DescribeInstanceTypes, and a cache of one of those. The exact-peak case needs no threshold
at all, because AWS reports both numbers; only the secondary "well above the baseline"
heuristic carries one (1.5×, set below the reported case's 2.0× so that case is caught).doctornow prints both figures —7.5 Gbps baseline (peak 15.0 — --nic-gbps wants the baseline, not this)— where it printed one unlabelled number, which is what made copying
the wrong one easy. The peak is shown only when it is a distinct measured figure;
ethtool, the estimate and the fallback all set peak = baseline, and printing "peak 10.0"
for an assumption would invent a fact.The guard had no test at all before this change.
-
NIC detection reported 50 Gbps on a 600 Gbps box, and said nothing about why
(#317). Reported externally from a
c8gn.48xlarge. Two independent defects, one visible and one not.The estimate table has no family dimension.
sizeBandwidthGbpsis keyed on the size
suffix alone, soc8gn.48xlarge— rated 600 Gbps across two network cards — resolved
through"48xlarge"to 50, a confident figure 12× low, logged at INFO with no
warning anywhere. The uncomfortable part is that the code already knew: the table's own
comment read "Network-optimized ('n') and metal variants exceed these". It documented the
blind spot and returned a number into it.The estimate now declines for any family carrying the
nattribute (c5n,c7gn,
c8gn,m5n,m6idn,i3en,im4gn,is4gen,d3en, …), so the chain falls to the
fallback, the mount reports the NIC as undetected anddoctorWARNs. That makes the figure
lower — 10 Gbps, in ratio further from 600 — and the signal correct, which is the trade
worth making: a wrong number that presents as detected gets acted on, an admitted unknown
gets asked about. It is the same callEvidenceRatioFormakes for an unknown region. The
attribute is read after the generation digits, sotrn1andinf2— whosenis part
of the family name — are not misclassified.And every source that fails now says why, in the startup log and in
doctor. The chain
previously reported only its winner, which hid the actionable case: on a ParallelCluster head
node theDescribeInstanceTypescall is refused (the default node roles do not grant
ec2:DescribeInstanceTypes), lith fell through to the size estimate, and nothing anywhere
said the precise path had been denied. The reporter could not distinguish a box that had been
refused from one that had never asked, so there was no reason to think an exact figure was one
IAM action away.ethtoolandDescribeInstanceTypesnow return reasons rather than a bare
0/false, because their failure modes call for different actions — install it, fix the
route, accept that ENA has no speed to report, grant one IAM action, retry a timeout.The reasons are reported at WARN when the winning figure was not measured (
fallbackor
imds-estimate) and at DEBUG/INFO behind a measured one, so a normal startup is quiet and an
unmeasured one is not.doctornow WARNs onimds-estimateas well, where it was an
INFO inside an all-PASS report. The reasons are never written to the NIC cache — they
describe one resolution on one box at one moment, and a cached "denied" would be replayed on
runs that never called the API.Known residual, named rather than quietly left: families whose network far exceeds the table
without ann—hpc7g/hpc7a,p4d/p5,trn1— and the flatmetalentry. Each
would need its own rule, none has been reported, and inventing rules for families nobody has
run is how a table gets a second generation of wrong entries. -
--prefetch-sibling-coverage's--helpdescribed only one of its two conditions
(#316). Reported externally. I wrote that
help text before a test forced the design correction that added the sibling condition, and
then updatedknobs.mdand the code comments without revisiting the flag's own help — so
the one place an operator actually reads described "already demanded" alone.That matters rather than being cosmetic: the sibling condition is what keeps a lone
strided reader from discounting its own sub-chunk holes and establishing, which is
#222's shape. Someone reading the old help
would have had no way to know why the flag did nothing for a single reader, or that it was
deliberately doing nothing. The help now states both conditions, which hole sizes each one
covers, and the measured result.