Summary
Sleeping the machine during a model download leaves the file on disk at its correct
final length with the undownloaded remainder reading as zeros, and nothing in the SDK
notices. IsCachedAsync
returns true, GetPathAsync returns a populated directory, the reported size and
revision match, and a subsequent DownloadAsync returns immediately because the model
is considered present. The only symptom appears much later, at LoadAsync, as an
opaque QNN error 14001.
The cause is that the destination file is preallocated to its full length and is not
marked sparse, so a partly complete download is indistinguishable by size from a
complete one, and no content hash is recorded or checked anywhere in the pipeline. We
captured the intermediate state on disk; see "Update: we caught the cause on disk"
below for the state file and the valid data length measurement.
The result is that an application has no supported way to tell a corrupt cache apart
from an unsupported machine, and no supported way to repair one. Both present
identically: the model is cached, and it will not load.
Environment
|
|
| SDK |
Microsoft.AI.Foundry.Local.WinML 1.0.0 (Foundry Local Core 1.1.0) |
| Model |
qwen2.5-7b-instruct-qnn-npu:3 (alias qwen2.5-7b) |
| ORT |
1.23.x native bundled with the SDK, Microsoft.ML.OnnxRuntime.Managed 1.23.2 |
| Host |
Snapdragon X Elite (X1P64100), Windows 11 arm64 10.0.26200 |
| EP |
MicrosoftCorporationII.WinML.Qualcomm.QNN.EP.1.8 1.8.30.0, QnnHtp 2.40.0.0 |
| NPU driver |
30.0.220.3000, Compute DSP 2.0.4478.2200 |
| App |
MSIX-packaged WinUI 3 / .NET 10 desktop app |
Two physically identical machines with byte-identical EP, driver, SoC, OS build and
RAM. The model loads on one and fails on the other.
What we observed
On the failing machine, LoadAsync throws with ONNX Runtime text forwarded verbatim:
Failed to create context from binary
QnnBackendManager::LoadCachedQnnContextFromBuffer
Error code: 14001
Before reaching a cache theory we eliminated, on both machines:
- Execution provider.
EP.1.8 1.8.30.0 is self-contained: it ships QnnHtp.dll,
QnnHtpV73Stub.dll, libQnnHtpV73Skel.so and libqnnhtpv73.cat. Nothing is missing
on the failing box.
- Driver and SoC. Identical, to the build number.
- Signature catalogs. No skel catalogs registered in
CatRoot on either machine.
- Model size and memory.
qwen2.5-0.5b-instruct-qnn-npu,
qwen2.5-1.5b-instruct-qnn-npu and phi-3.5-mini-instruct-qnn-npu all load
correctly on the failing machine, and all three are built with the same QNN SDK
2.37.1 as the 7B. So the EP, the driver, the SDK version and the NPU itself are all
working on that machine.
That left the bytes. We captured a manifest of the model cache from the working
machine (33 files, 6.81 GB, SHA-256 per file) and compared it against the failing
machine. Exactly one file differed:
CORRUPT context_1_ctx_qnn.bin, right size but different contents
470,421,504 bytes on both machines
Deleting the cached model and downloading it again fixed the machine.
We had watched Foundry's downloader abort and resume twice at roughly 59% earlier in
the same session, with 116 GB free on the target volume. At the time we framed this
report around detection rather than cause, because the mechanism was inference. We have
since reproduced the cause and captured the on-disk state; see "Update: we caught the
cause on disk" below. The detection gap is still the primary ask, because it is what
leaves an application unable to recover.
These bytes were not written by our code. The application never fetches model
files itself. Every download goes through ModelVariant.DownloadAsync with a progress
callback and nothing else; the only HttpClient in the codebase talks to an unrelated
local service. We also apply no timeout to the download, so we never cancel one on a
timer. The one way our process could interrupt a transfer is the user closing the app
mid-download, which is an ordinary real-world interruption rather than an induced one,
and is precisely the case a resume path needs to survive.
Why this is hard to handle from an application
The failure surfaces roughly 470 MB and one process boundary away from its cause, and
every signal the SDK offers says the cache is fine:
IsCachedAsync returns true. It appears to check for presence, not integrity.
There is no checksum verification at the end of a download and no way to request one.
DownloadAsync is a no-op once cached. An app that catches the load failure and
retries the download gets an immediate success and the same broken bytes. There is
no force or verify parameter.
- There is no obvious supported repair.
RemoveFromCacheAsync exists but is not
discoverable as the documented remedy for this, and nothing in the load exception
suggests reaching for it.
- The error text names no file. QNN 14001 identifies neither the context binary
nor the cache path, so the natural reading is "this machine cannot run this model",
which is exactly the wrong conclusion and sends you off measuring RAM and driver
versions, as it sent us.
To ship, we had to build all of this ourselves: capture a reference manifest on a known
good machine, ship it as an app asset, hash 6.81 GB of cache on the user's machine, and
add our own delete-and-redownload path. That is a lot of machinery to compensate for a
download that did not verify itself, and it only works because we happened to have a
second machine to take a reference from. An application shipping to real users has no
such machine.
Update: we caught the cause on disk
After filing the first draft we hit this again, and this time captured the intermediate
state. The trigger is machine sleep during a large download, which for a 7 GB model
on a laptop is close to unavoidable. On wake the transfer stopped advancing, then the
progress reporting ended as though it had finished.
The download writes through a chunked, parallel path (DownloadBlobChunked ->
DownloadChunkWithRetry -> WriteAtOffsetAsync) with resume state persisted next to
the payload as <file>.download.state. Here is a real one, mid-transfer:
{
"BlobSize": 470364160,
"ChunkSize": 2097152,
"TotalChunks": 225,
"BitmapByteAlignedStart": 0,
"HighestCompletedChunk": 88,
"CompletedCount": 47,
"TruncCompletionBitmap": "IMqH+NRP+wzXXqEB",
"LastModified": "2026-07-25T21:19:51.2341093Z"
}
47 of 225 chunks were complete. The corresponding iterator_3_ctx_qnn.bin on disk:
| Property |
Value |
| File length |
470,364,160 |
BlobSize |
470,364,160 (identical) |
| Valid data length |
224,624,640 (47.75%) |
| Sparse flag |
not set |
The destination file is preallocated to its full final length on the first write. It
is not marked sparse, so it consumes its full size on disk and every byte past the valid
data length reads back as a well formed run of zeros. A file that is 47% downloaded is
indistinguishable, by length, from a file that is complete. We confirmed by reading at
chunk offsets: data past the valid data length is all zero, and the tail is not
recoverable from the file alone.
That is the whole failure. Any completeness check that trusts length, and there is no
hash in the persisted state to check instead, will accept this file. It explains the
symptom in the original report exactly: the cache reports healthy at every step, and the
first thing that actually reads the bytes is QNN, which fails at 14001.
Two smaller observations from the same artifact, offered as detail rather than as
separate asks:
- The bitmap and the data are not consistent with each other. Chunks marked incomplete
below the high water mark still contained data, so the state file lags the writes. That
direction is safe, it just re-downloads, but it does show the state file and the
payload are not committed together, and the opposite direction would not be safe.
HighestCompletedChunk (88) is far ahead of CompletedCount (47), which is expected
with parallel chunks, but it means the completed region is full of holes rather than a
clean prefix. Resume correctness rests entirely on the bitmap surviving accurately.
We could not find any content verification in the download path. The persisted state
carries a size and a LastModified, but no ETag and no content hash, and there is no
post-download hash comparison, so nothing in the pipeline can distinguish a zero filled
region from real model weights.
Why this points at the transport
Sleep is a hostile case for a long lived HTTPS transfer: sockets are dead on wake, and
the failure is silent rather than an error. Browsers and the OS download stacks have
spent years on exactly this, and package delivery already solves it too. An MSIX is
content addressed by 64 KB block hashes in a signed AppxBlockMap.xml, so a resumed or
partial transfer cannot be mistaken for a complete one, and blocks the machine already
has are not fetched again. This is one of the reasons we are asking, in the companion
report, for models to be shippable inside the application package: it moves this problem
onto a transport that has already solved it, rather than solving it again in the SDK.
Steps to reproduce
The cause is now known: interrupt a large model download by sleeping the machine, wake
it, and let the transfer end. The resulting file is full length with a short valid data
length.
The deterministic reproduction below simulates the result rather than the cause, and
still demonstrates the detection gap, which is the part we are asking about:
-
Download a QNN NPU model, for example qwen2.5-7b-instruct-qnn-npu:3, and confirm
it loads.
-
Locate the cache via GetPathAsync. In our case
%USERPROFILE%\.<AppName>\cache\models\Microsoft\qwen2.5-7b-instruct-qnn-npu-3\v3.
-
Corrupt a context binary in place without changing its length:
$f = "<cache>\context_1_ctx_qnn.bin"
$fs = [IO.File]::Open($f, 'Open', 'Write')
$fs.Seek(250MB, 'Begin') | Out-Null
$fs.Write((New-Object byte[] 4096), 0, 4096)
$fs.Dispose()
-
In a fresh process:
var variant = await catalog.GetModelVariantAsync("qwen2.5-7b-instruct-qnn-npu:3");
Console.WriteLine(await variant.IsCachedAsync()); // true
await variant.DownloadAsync(); // returns immediately, no repair
Console.WriteLine(await variant.IsCachedAsync()); // still true
await variant.LoadAsync(); // throws, QNN 14001
Expected: either the download verifies and repairs the file, or IsCachedAsync
reports the cache as unusable, or the load error identifies the file that failed to
parse.
Actual: the cache reports healthy at every step and the load fails with an error
that points at the accelerator rather than at the bytes.
Suggested fixes, roughly in order of value
- Verify after download. Compare each file against the size and hash the service
already publishes in the model manifest, and fail the download rather than the load.
This alone would have turned a multi-day investigation into an error message.
- Make
DownloadAsync repairable. A force: true or verify: true option so an
application can recover without reverse-engineering the cache layout.
- Add an integrity API. Something like
VerifyCacheAsync returning the offending
files, so applications do not each have to reimplement hashing and ship their own
reference manifests.
- Name the file in the load failure. Wrapping ORT's message with the context
binary path and the cache directory would let a developer reach the answer directly
from the exception.
- Harden the resume path. Do not let a preallocated full length file stand in for a
complete one. Any of the following would have prevented this outright: keep the
partial payload under a temporary name and rename only on verified completion, mark
the destination sparse so a short valid data length is visible, record a content hash
in .download.state and check it before declaring completion, or persist an ETag and
revalidate on resume rather than trusting LastModified and length. Committing the
state file and the payload writes together would also close the gap between the
bitmap and the bytes.
- Treat a wake from sleep as a transfer restart. The failure we hit was silent
rather than an error, so retry logic never engaged.
Item 1 is the one that matters. The rest are mitigations for the case where it fails
anyway.
Related
The same investigation ran into a second, separate problem worth mentioning because it
compounds this one: FoundryLocalManager.IsInitialized flips to true after
CreateAsync but before DownloadAndRegisterEpsAsync completes. Touching the
catalog in that window caches CPU-only variants for the lifetime of the process, so an
NPU model silently never gets offered. We work around it by awaiting EP registration
explicitly before any catalog access. Happy to file that separately if useful.
Summary
Sleeping the machine during a model download leaves the file on disk at its correct
final length with the undownloaded remainder reading as zeros, and nothing in the SDK
notices.
IsCachedAsyncreturns
true,GetPathAsyncreturns a populated directory, the reported size andrevision match, and a subsequent
DownloadAsyncreturns immediately because the modelis considered present. The only symptom appears much later, at
LoadAsync, as anopaque QNN error 14001.
The cause is that the destination file is preallocated to its full length and is not
marked sparse, so a partly complete download is indistinguishable by size from a
complete one, and no content hash is recorded or checked anywhere in the pipeline. We
captured the intermediate state on disk; see "Update: we caught the cause on disk"
below for the state file and the valid data length measurement.
The result is that an application has no supported way to tell a corrupt cache apart
from an unsupported machine, and no supported way to repair one. Both present
identically: the model is cached, and it will not load.
Environment
Microsoft.AI.Foundry.Local.WinML1.0.0 (Foundry Local Core 1.1.0)qwen2.5-7b-instruct-qnn-npu:3(aliasqwen2.5-7b)Microsoft.ML.OnnxRuntime.Managed1.23.2MicrosoftCorporationII.WinML.Qualcomm.QNN.EP.1.81.8.30.0, QnnHtp 2.40.0.0Two physically identical machines with byte-identical EP, driver, SoC, OS build and
RAM. The model loads on one and fails on the other.
What we observed
On the failing machine,
LoadAsyncthrows with ONNX Runtime text forwarded verbatim:Before reaching a cache theory we eliminated, on both machines:
EP.1.81.8.30.0 is self-contained: it shipsQnnHtp.dll,QnnHtpV73Stub.dll,libQnnHtpV73Skel.soandlibqnnhtpv73.cat. Nothing is missingon the failing box.
CatRooton either machine.qwen2.5-0.5b-instruct-qnn-npu,qwen2.5-1.5b-instruct-qnn-npuandphi-3.5-mini-instruct-qnn-npuall loadcorrectly on the failing machine, and all three are built with the same QNN SDK
2.37.1 as the 7B. So the EP, the driver, the SDK version and the NPU itself are all
working on that machine.
That left the bytes. We captured a manifest of the model cache from the working
machine (33 files, 6.81 GB, SHA-256 per file) and compared it against the failing
machine. Exactly one file differed:
Deleting the cached model and downloading it again fixed the machine.
We had watched Foundry's downloader abort and resume twice at roughly 59% earlier in
the same session, with 116 GB free on the target volume. At the time we framed this
report around detection rather than cause, because the mechanism was inference. We have
since reproduced the cause and captured the on-disk state; see "Update: we caught the
cause on disk" below. The detection gap is still the primary ask, because it is what
leaves an application unable to recover.
These bytes were not written by our code. The application never fetches model
files itself. Every download goes through
ModelVariant.DownloadAsyncwith a progresscallback and nothing else; the only
HttpClientin the codebase talks to an unrelatedlocal service. We also apply no timeout to the download, so we never cancel one on a
timer. The one way our process could interrupt a transfer is the user closing the app
mid-download, which is an ordinary real-world interruption rather than an induced one,
and is precisely the case a resume path needs to survive.
Why this is hard to handle from an application
The failure surfaces roughly 470 MB and one process boundary away from its cause, and
every signal the SDK offers says the cache is fine:
IsCachedAsyncreturnstrue. It appears to check for presence, not integrity.There is no checksum verification at the end of a download and no way to request one.
DownloadAsyncis a no-op once cached. An app that catches the load failure andretries the download gets an immediate success and the same broken bytes. There is
no
forceorverifyparameter.RemoveFromCacheAsyncexists but is notdiscoverable as the documented remedy for this, and nothing in the load exception
suggests reaching for it.
nor the cache path, so the natural reading is "this machine cannot run this model",
which is exactly the wrong conclusion and sends you off measuring RAM and driver
versions, as it sent us.
To ship, we had to build all of this ourselves: capture a reference manifest on a known
good machine, ship it as an app asset, hash 6.81 GB of cache on the user's machine, and
add our own delete-and-redownload path. That is a lot of machinery to compensate for a
download that did not verify itself, and it only works because we happened to have a
second machine to take a reference from. An application shipping to real users has no
such machine.
Update: we caught the cause on disk
After filing the first draft we hit this again, and this time captured the intermediate
state. The trigger is machine sleep during a large download, which for a 7 GB model
on a laptop is close to unavoidable. On wake the transfer stopped advancing, then the
progress reporting ended as though it had finished.
The download writes through a chunked, parallel path (
DownloadBlobChunked->DownloadChunkWithRetry->WriteAtOffsetAsync) with resume state persisted next tothe payload as
<file>.download.state. Here is a real one, mid-transfer:{ "BlobSize": 470364160, "ChunkSize": 2097152, "TotalChunks": 225, "BitmapByteAlignedStart": 0, "HighestCompletedChunk": 88, "CompletedCount": 47, "TruncCompletionBitmap": "IMqH+NRP+wzXXqEB", "LastModified": "2026-07-25T21:19:51.2341093Z" }47 of 225 chunks were complete. The corresponding
iterator_3_ctx_qnn.binon disk:BlobSizeThe destination file is preallocated to its full final length on the first write. It
is not marked sparse, so it consumes its full size on disk and every byte past the valid
data length reads back as a well formed run of zeros. A file that is 47% downloaded is
indistinguishable, by length, from a file that is complete. We confirmed by reading at
chunk offsets: data past the valid data length is all zero, and the tail is not
recoverable from the file alone.
That is the whole failure. Any completeness check that trusts length, and there is no
hash in the persisted state to check instead, will accept this file. It explains the
symptom in the original report exactly: the cache reports healthy at every step, and the
first thing that actually reads the bytes is QNN, which fails at 14001.
Two smaller observations from the same artifact, offered as detail rather than as
separate asks:
below the high water mark still contained data, so the state file lags the writes. That
direction is safe, it just re-downloads, but it does show the state file and the
payload are not committed together, and the opposite direction would not be safe.
HighestCompletedChunk(88) is far ahead ofCompletedCount(47), which is expectedwith parallel chunks, but it means the completed region is full of holes rather than a
clean prefix. Resume correctness rests entirely on the bitmap surviving accurately.
We could not find any content verification in the download path. The persisted state
carries a size and a
LastModified, but no ETag and no content hash, and there is nopost-download hash comparison, so nothing in the pipeline can distinguish a zero filled
region from real model weights.
Why this points at the transport
Sleep is a hostile case for a long lived HTTPS transfer: sockets are dead on wake, and
the failure is silent rather than an error. Browsers and the OS download stacks have
spent years on exactly this, and package delivery already solves it too. An MSIX is
content addressed by 64 KB block hashes in a signed
AppxBlockMap.xml, so a resumed orpartial transfer cannot be mistaken for a complete one, and blocks the machine already
has are not fetched again. This is one of the reasons we are asking, in the companion
report, for models to be shippable inside the application package: it moves this problem
onto a transport that has already solved it, rather than solving it again in the SDK.
Steps to reproduce
The cause is now known: interrupt a large model download by sleeping the machine, wake
it, and let the transfer end. The resulting file is full length with a short valid data
length.
The deterministic reproduction below simulates the result rather than the cause, and
still demonstrates the detection gap, which is the part we are asking about:
Download a QNN NPU model, for example
qwen2.5-7b-instruct-qnn-npu:3, and confirmit loads.
Locate the cache via
GetPathAsync. In our case%USERPROFILE%\.<AppName>\cache\models\Microsoft\qwen2.5-7b-instruct-qnn-npu-3\v3.Corrupt a context binary in place without changing its length:
In a fresh process:
Expected: either the download verifies and repairs the file, or
IsCachedAsyncreports the cache as unusable, or the load error identifies the file that failed to
parse.
Actual: the cache reports healthy at every step and the load fails with an error
that points at the accelerator rather than at the bytes.
Suggested fixes, roughly in order of value
already publishes in the model manifest, and fail the download rather than the load.
This alone would have turned a multi-day investigation into an error message.
DownloadAsyncrepairable. Aforce: trueorverify: trueoption so anapplication can recover without reverse-engineering the cache layout.
VerifyCacheAsyncreturning the offendingfiles, so applications do not each have to reimplement hashing and ship their own
reference manifests.
binary path and the cache directory would let a developer reach the answer directly
from the exception.
complete one. Any of the following would have prevented this outright: keep the
partial payload under a temporary name and rename only on verified completion, mark
the destination sparse so a short valid data length is visible, record a content hash
in
.download.stateand check it before declaring completion, or persist an ETag andrevalidate on resume rather than trusting
LastModifiedand length. Committing thestate file and the payload writes together would also close the gap between the
bitmap and the bytes.
rather than an error, so retry logic never engaged.
Item 1 is the one that matters. The rest are mitigations for the case where it fails
anyway.
Related
The same investigation ran into a second, separate problem worth mentioning because it
compounds this one:
FoundryLocalManager.IsInitializedflips totrueafterCreateAsyncbut beforeDownloadAndRegisterEpsAsynccompletes. Touching thecatalog in that window caches CPU-only variants for the lifetime of the process, so an
NPU model silently never gets offered. We work around it by awaiting EP registration
explicitly before any catalog access. Happy to file that separately if useful.