fix(inference): concurrent suggests encode once and build one provider - #575
Open
JArmandoAnaya wants to merge 2 commits into
Open
fix(inference): concurrent suggests encode once and build one provider#575JArmandoAnaya wants to merge 2 commits into
JArmandoAnaya wants to merge 2 commits into
Conversation
Two clicks in flight on the same un-encoded asset both encoded it, and two concurrent first clicks both built a provider — one process was observed loading the model four times, and one burst of concurrent suggests took the development stack down with it. Both sites had the same shape: find nothing, compute, store, with a window between the finding and the storing wide enough to drive a model load through. `KeyedLocks` closes it per key, so a second caller for the same asset or the same connection waits for the first rather than duplicating it, while two different assets still encode in parallel. `BoundedCache` also takes a lock of its own. It had documented itself as deliberately not thread safe on the reasoning that a handler is serialised by the device it talks to; the suggest route is a plain `def`, so FastAPI answers concurrent requests in parallel threadpool threads and that reasoning does not hold. The lock protects the container's own linked list and is never held across a computation. The load site now records why the `sam2_video` checkpoint warning is expected: `output_loading_info` reports no missing, unexpected or mismatched keys, so nothing is left randomly initialised, and `Sam2VideoModel` cannot answer a point on a single image.
With no `__init__.py` anywhere, pytest identifies a test module by basename alone, so `tests/inference/test_concurrency.py` beside the kernel's file of the same name is a collection error rather than two modules. Running pytest staged by directory, which is how the local gate survives the harness ceiling, structurally cannot see this: each directory collects cleanly on its own and only a whole-suite collection fails.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three items were dispatched against the suggest tool's latency, from an instrumented diagnosis.
One of the three was falsified before a line was written, and that is the more useful half of
this change: the autograd leak that was the leading explanation for a 4.5 s warm click does not
exist. What is left is a real concurrency defect, which is fixed here, and a checkpoint warning
that turns out to be correct behaviour, which is now recorded where somebody will read it.
Closes #571.
Premise verification
Both premises the dispatch was built on were checked against
HEADbefore any work started, andone did not survive.
The running compose stack is bound to a different worktree than this one —
docker inspect visionset-api-1reports its mounts coming fromVisionSet-wt/slider-drag, the branch behind theopen detail-slider pull request (#565). That worktree was treated as a banned surface and not
touched. Every measurement below states the checkout and commit that produced it, and every
absolute number is a CPU figure from the CPU-inference image (
torch 2.13.0+cpu, 16 coresvisible,
torch.get_num_threads()reporting 8). None of them transfer to a machine with a GPU.A. The autograd leak does not exist
The hypothesis was that
LocalSamProvider._embeddingruns its encode outside the guard the decodeuses, so cached embeddings might carry a live autograd graph that every later decode pays for.
It does run outside the guard. It does not matter, because
transformerscloses it one leveldown:
Sam2Model.get_image_embeddingsis itself decorated@torch.no_grad()in the lockedtransformers 5.14.1. Measured rather than read — a probe built the real provider against the real
checkpoint and then read the cache:
Every cached tensor is already inert. No guard and no
detach()were added, because a testasserting inertness passes on unmodified
HEAD— that is red-before-green failed — and code thatchanges nothing cannot be mutation-verified. Two things worth keeping from the probe anyway: the
embedding is a list of three tensors rather than one, which any future detach would have to map
over, and one asset's embedding costs 16 MiB, so eight of them is 128 MiB and the cache is not
where the memory went.
A trap that was avoided and is worth recording for whoever revisits this:
torch.inference_mode()is not interchangeable with
no_grad()here. Tensors created under inference mode areinference tensors, the decode runs under
no_grad()rather than inference mode, and using onethere raises at runtime — so a cache that spans the two contexts must not be filled under
inference mode.
The warm click is therefore unchanged, and this pull request does not claim otherwise.
a7e53c9)d403e37)Within noise, as expected: the decode was always the cost and nothing here touches it. What the
decode's 4.5 s actually is remains open and is not diagnosed in this change.
B. Concurrent suggests encode once and build one provider
This is the item that carried the work. Both sites had the same shape — find nothing, compute,
store — with a window between the finding and the storing wide enough to drive a model load
through. In production that window was observed being taken: two clicks on one un-encoded asset
both encoded it, two concurrent first clicks both built a provider, one process loaded the model
four times, and one burst of concurrent suggests took the whole development stack down with nginx
exiting 137.
KeyedLocksincache.pycloses it per key. A second caller for the same asset, or the sameconnection, waits for the first rather than duplicating it; two different assets still encode in
parallel, because a global lock would trade a duplicated-work problem for a queueing problem and
cost exactly the latency the cache exists to save. The cache is read twice on purpose: the first
read is the common case and takes no lock at all, the second is what the loser of a race sees.
The
BoundedCachecontract choice, which the dispatch asked to be statedBoundedCacheis now internally thread safe, rather than being left lock-free with allsynchronisation at its callers. Its docstring had claimed it was deliberately not thread safe, on
the reasoning that a worker process runs one task at a time and a server handler is serialised by
the device it talks to. Neither holds:
suggest_regionis a plaindef, so FastAPI answersconcurrent requests in parallel threadpool threads. Two threads calling
move_to_endfordifferent keys are still mutating one
OrderedDict's linked list, which no amount of per-keylocking at the caller protects. The lock is never held across a computation, so it cannot
serialise two encodes; that is
KeyedLocks' job, and the two are kept separate because they answerdifferent questions.
C. The
sam2_videowarning is correct behaviour, and is now documented in placetransformerswarns on every load that asam2_video-typed checkpoint is being instantiated asSam2Model. Asking the loader directly settles it:Nothing is left randomly initialised and nothing in the checkpoint goes unused, so the load is
exact. The alternative class cannot serve this call at all:
Sam2VideoModel.forwardtakes aninference_sessionand a frame index, which is the video-tracking path with no way to answer apoint on a single image. Why
sam2_videois a served family is already argued infamilies.py;what was missing was the evidence at the load site, and
_loadnow carries it.The warning is left visible. Silencing it would hide the same sentence on the day a checkpoint
genuinely does not match, and that day it is the only warning there is.
Red before green
tests/inference/test_concurrency.pyon unmodifieda7e53c9:The file's other three cases pass on
HEADand are guards rather than reproductions — statedplainly, because a green test presented as a fix is worse than no test.
It is the suite's second threaded file, and it follows the first one's rules
(
tests/kernel/test_concurrency.py): every wait is athreadingprimitive rather than a sleep,every thread is joined with a timeout and then asserted dead, and failures inside threads are
returned rather than printed and lost. Overlap is asserted through a barrier, never through
wall-clock — a barrier that releases proves two encodes were genuinely in flight, one that times
out proves they were not, and neither reading depends on how fast the machine is. Repeated 20
consecutive times, 0 failures before shipping.
Mutation verification
Work committed before the first mutation. Each step an unconditional statement rather than a link
in an
&&chain, anchor asserted present exactly once before and the replacement after, revertedby
git apply -Ron the recorded patch, tree asserted clean between cases._embedding's per-key lock →if True:test_concurrent_clicks_on_one_asset_encode_it_onceProviderPool.get's per-key lock →if True:test_concurrent_first_clicks_build_one_provider,test_concurrent_callers_all_receive_the_same_providerKeyedLocks.for_keyreturns a fresh lock each calltest_one_key_answers_with_one_lock, and all three concurrency testsBoundedCache._lock→nullcontext()The fourth row is honest rather than tidy. The
BoundedCachelock guards the container's internalconsistency under concurrent mutation of different keys, and no deterministic test can observe
that: a stress test over an
OrderedDictstays green without the lock on CPython in practice,which would be a test that cannot fail rather than coverage. The rule is stated in the docstring
and left unverified, which is the accurate description of it.
The harness itself had a bug worth recording, because it is the failure mode the protocol warns
about: the first attempt passed two test paths as a single unquoted parameter, and zsh does not
word-split unquoted parameters the way bash does, so pytest was handed one nonexistent path and
exited 4. That reads as a mutation nothing covered. It was caught by the exit code not being 1,
re-run correctly, and the row above is from the corrected run.
The gate
Staged, because the harness kills a command at about ten minutes. Directories derived from
ls tests/at run time rather than from a remembered list. Every stage's exit code:The browser stage, and the declared fallback
bash scripts/check.sh browserdid not come back clean on this machine, and the reason is themachine rather than the change. Another session owns the box: fifteen vitest worker processes
belonging to a different worktree,
VisionSet-wt/mps-device, at a load average of 137.46 /226.73 / 155.71. Nothing belonging to that session was touched.
The four distinct scenarios involved across both runs are timing-sensitive: a scroll-into-view
assertion, a review round trip, a perf spec counting DOM mutations per pointer move, and a token
gate. Run together in isolation on the same worktree they pass 4 of 4 in 32.1 s, and the
single scenario from run 1 passes
--repeat-each=3. The change is Python-only, confined tosrc/visionset/inference/, with no route, wire shape or frontend file touched, so none of thesespecs exercise anything this diff changes.
Per the protocol this is the declared fallback rather than a silent one, and it is declared here:
the two browser suites did not produce a clean full run locally, every other gate did, and CI
on clean runners is the arbiter.
CI is the arbiter, and it agrees
All fourteen required checks are green on clean runners,
annotator e2e (chromium)among them —which settles the local browser runs above as load rather than regression. (
annotator bench (chromium, manual)isworkflow_dispatch-only and correctly reports as skipping.)One repair, and what hid the defect
The first CI run failed
pythonat collection:With no
__init__.pyanywhere, pytest identifies a module by basename alone, so the new filecould not share one with the kernel's threaded file. It is now
tests/inference/test_provider_concurrency.py.What is worth recording is why the local gate could not catch it. The gate is staged by test
directory to survive the harness's ten-minute ceiling, and each directory collects perfectly well
on its own — the collision exists only in a whole-suite collection. A staged run is not merely
slower to notice this; it is structurally blind to it.
uv run pytest --collect-only -qover thewhole tree costs seconds and would have caught it, and is the cheap companion to a staged run.
Found, not fixed
The annotation workspace fetches an asset's content twice on every page load, same URL, back
to back. Filed separately as #572 and deliberately left for a frontend change.
Flags for Armando
4.6 s and roughly 95% of it is
model(...). What that 4.5 s actually is remains undiagnosed;the dispatch explicitly ruled out chasing it here, so A warm suggest click costs about 4.8 s, and almost all of it is the model's own decode #571 will need a follow-up decision about
whether to profile the forward pass. Related but not the same cost: the full-resolution mask
conversion tracked in The suggest adapter converts a full-resolution mask into Python lists on every click #561 measures 2.1–24.9 ms at 854×480 and is not the problem at this
resolution.
measured, 72.7 ms, came from the one request that had performed its own encode immediately
beforehand; every decode reading a cached embedding costs about 4.55 s. Sixtyfold, same model,
same asset, same process. Now that the autograd explanation is dead, this is unexplained rather
than merely unverified, and it looks like the most promising thread for the follow-up.
BoundedCachecontract changed, from documented-not-thread-safe to internally locked.The reasoning is in the docstring. It is the one change here that no test can verify.
src/visionset/inference/and its tests — no frontend, no wire shape, noallowed_actions— so this is Tier A and the pull request is opened at completion, per thedispatch. Item C resolved to documentation, so no produced mask changes; had it changed the
loaded class it would have been Tier B.
issue.
Agent-response.tmp.mdis gone from every checkout, and A warm suggest click costs about 4.8 s, and almost all of it is the model's own decode #571 carries the findings.