Skip to content

Reject foreign allocation handles in the DirectX EP allocator (PLAT-207667) - #106

Draft
danieyan-amd wants to merge 2 commits into
mainfrom
fix/plat-207667-dml-decode-handle
Draft

Reject foreign allocation handles in the DirectX EP allocator (PLAT-207667)#106
danieyan-amd wants to merge 2 commits into
mainfrom
fix/plat-207667-dml-decode-handle

Conversation

@danieyan-amd

Copy link
Copy Markdown
Collaborator

Summary

Fixes a crash in the AMD GPU EP's DirectML backend where a pointer the DML allocator never produced is decoded as a PluginDmlAllocationInfo* and dereferenced. Reported as PLAT-207667 (Amuse on Medusa/MDS1); it reproduces both when generating with SD3 Medium and when installing SD1.5.

Root cause

DmlBucketizedBufferAllocator::DecodeDataHandle only null-checks and then static_casts. For GPU/DEFAULT memtype the factory can hand back a CpuAllocator stub whose Alloc is plain malloc; that pointer is non-null, so it passes the check and is then treated as a PluginDmlAllocationInfo — every subsequent field read faults.

dml_factory.cc already documents this exact scenario in a comment ("blind-cast to a PluginDmlAllocationInfo* and AddRef'd through a garbage vtable -> 0xC0000005"); this change makes it unreachable. I confirmed at runtime that the fallthrough which produces the stub really is taken.

Crash dumps show two fault sites, one bug:

Site Instruction Exception
directx-backend.dll+0x587af mov rax,[rcx+8] — vtable slot for AddRef 0xC0000005
directx-backend.dll+0x4f5aa mov rax,[rax+0x18] — member read on the GetResource path 0xC0000005

A third crash is 0xC0000409 FAST_FAIL_GUARD_ICALL_CHECK_FAILURE — the same defect when the garbage vtable slot happens to be a non-null invalid target, so Control Flow Guard blocks the indirect call.

Not fixed in any shipped build: the newest pipeline build (msftpipeline/20260828-10D-RC9, 1.8.99.26) still has the same null-check-and-cast stub, and main is unpatched.

Changes

  1. dml_bucketized_buffer_allocator.{h,cc} — the allocators track the handles they hand out; DecodeDataHandle rejects anything else with E_INVALIDARG. Validation is a registry lookup rather than an owner check, because reading GetOwner() would itself dereference the untrusted pointer — the exact fault at +0x4f5aa. The registry is process-wide so a handle that legitimately crosses allocator instances (sessions sharing tensors) still decodes.

  2. dml_factory.ccCreateAllocatorImpl no longer substitutes a CPU allocator for GPU memory once an EP exists; it returns ORT_EP_FAIL so session creation fails recoverably instead of handing out malloc'd pointers. Deliberately scoped to m_ep_raw != nullptr: ORT also requests a GPU allocator during factory registration, before any EP exists, and failing there breaks registration outright (observed while testing).

Testing

Built the DirectX backend locally (--use_dml, ORT 1.27) and ran a Conv/Relu/Add/ReduceMean model through the rebuilt EP: it registers, is selected, and infers correctly — so the validation rejects no legitimate handle. Also verified in the binary that DecodeDataHandle now performs the lookup rather than the bare cast.

End-to-end validation on MDS1 still needs a signed build — that is the remaining step.

Reviewer notes

  • DecodeDataHandle now takes a lock and a hash lookup per call. If this shows up on GPU-path profiles, move the registry to a shared_mutex or a lock-free set.
  • This widens an existing throw (the null case already threw E_INVALIDARG) rather than adding a new one, so the exception-safety contract is unchanged. Most call sites are inside ORT_TRY; a few helpers (GetABIDataInterface, TryGetPooledAllocationId, GetAllocationFromDataPointer) rely on their callers — worth a second pair of eyes.
  • Workaround for affected users meanwhile: use the AMDGPU_DirectX or DirectML execution device; only the AMDGPU umbrella path crashes.

danieyan-amd added 2 commits September 2, 2026 11:20
…07667)

Amuse crashes on the AMDGPU execution device (Medusa/MDS1, EP 1.8.99.99) both
when generating with SD3 Medium and when installing SD1.5. Crash dump analysis
shows two distinct fault sites in directx-backend.dll, both reached the same way:

    [this+0xf8] (m_allocator) -> DecodeDataHandle(ptr) -> deref result

  * +0x587af  mov rax,[rcx+8]     reading the vtable slot for AddRef()
  * +0x4f5aa  mov rax,[rax+0x18]  reading a member on the GetResource() path

Depending on the garbage read, this surfaces as 0xC0000005 or, when the bogus
vtable slot is a non-null invalid target, as a Control Flow Guard fastfail
(0xC0000409, FAST_FAIL_GUARD_ICALL_CHECK_FAILURE).

Root cause: DecodeDataHandle only null-checks and then static_casts. A pointer
that this allocator never produced - notably one from the factory's CpuAllocator
stub, which is plain malloc with no D3D12 resource - is non-null, so it passes
the check and every subsequent field read faults. The QueryInterface
discrimination already present in one caller does not help, because its fallback
path still blind-casts.

Fix, two layers:

1. The allocators track the handles they hand out and DecodeDataHandle rejects
   anything else with E_INVALIDARG. Validation is a registry lookup rather than
   an owner check, because reading GetOwner() would itself dereference the
   untrusted pointer - the exact fault at +0x4f5aa. The registry is process-wide
   so a handle that legitimately crosses allocator instances (sessions sharing
   tensors) is still accepted.

2. ProviderFactory::CreateAllocatorImpl no longer falls through to the
   CpuAllocator stub for GPU memory once an EP exists; it returns ORT_EP_FAIL so
   session creation fails recoverably instead of handing out malloc'd pointers.
   This is deliberately scoped to m_ep_raw != nullptr: ORT also requests a GPU
   allocator during factory registration, before any EP exists, and failing there
   breaks registration outright (observed while testing). The stub returned on
   that path is harmless now that DecodeDataHandle validates.

Verified by building the DirectX backend locally (USE_DML, ORT 1.27) and running
a Conv/Relu/Add/ReduceMean model through the rebuilt EP: it registers, is
selected, and infers correctly, so the validation rejects no legitimate handle.

Note: DecodeDataHandle now takes a lock and a hash lookup per call. If this shows
up on GPU-path profiles, move the registry to a shared_mutex or a lock-free set.
DecodeDataHandle now rejects handles the allocator never produced, but three of
its callers dereference the result with no exception boundary, so the throw
escaped a noexcept frame and terminated the process (0xC0000409) instead of
crashing on the bad pointer (0xC0000005). Both are fatal; neither is diagnosable.

Guard those three so an invalid handle degrades to the result each caller already
handles: GetABIDataInterface yields a null interface, TryGetPooledAllocationId
returns 0 (the answer it already gives for an unpooled handle), and
GetAllocationFromDataPointer returns nullptr - it had a dormant '!alloc_info'
branch for exactly this case.

Verified on a local repro (SD3.5 vae_decoder through the DirectX EP, which
reproduces the reported access violation 3/3): shipped build crashes 0xC0000005,
this build returns ORT_FAIL 'DML operator Compute failed: op=Transpose
HR=0x80070057' 3/3 with the process intact.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


danieyan-amd seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@danieyan-amd

Copy link
Copy Markdown
Collaborator Author

Update — local repro found, fix verified end-to-end

The crash reproduces on ordinary hardware, no Amuse and no APU involved. Registering the shipped
directx-backend.dll (RC9, 7.14.2610.2) in a plain ORT Python session and running an SD3.5
vae_decoder faults 3/3 on a gfx1100 / RX 7900 XTX with 24 GB:

directx-backend.dll  0xC0000005  fault offset +0x5861f
  -> function +0x585d0..+0x58647   size 0x77, fault at +0x4f

That is the same function, same size and same fault position as the crash reported on the
1.8.99.99 build (+0x58760..+0x587d7, fault at +0x4f) — DecodeResource:

mov  rcx, [rbx+0xf8]      ; m_allocator
call <DecodeDataHandle>
mov  rbx, rax
test rax, rax / je        ; null-checked, and it passes
mov  rcx, [rax]           ; vtable
mov  rax, [rcx+8]         ; <== FAULT (AddRef slot)

A simple static Conv/Relu/Add/ReduceMean graph does not trigger it; it needs a real model, which
is consistent with "standard models pass, LLMs fail" reported elsewhere on the DirectX umbrella path.

Second commit: the rejection had to be made non-fatal

With only the first commit the process still died, just differently — 0xC0000409 instead of
0xC0000005. Three DecodeDataHandle callers dereference the result with no exception boundary, so
the new E_INVALIDARG escaped a noexcept frame and hit std::terminate:

Caller Previously Now
GetABIDataInterface DecodeDataHandle(data)->GetResource() *abiData = nullptr
TryGetPooledAllocationId DecodeDataHandle(data)->GetPooledResourceId() 0
GetAllocationFromDataPointer threw past its own null branch nullptr

Each degrades to a value the caller already handles — GetAllocationFromDataPointer even had a
dormant if (!alloc_info) return nullptr; for exactly this case, which suggests null-on-failure was
the intended contract.

Result

Build Result Runs
shipped RC9 0xC0000005 crash 3/3
this branch ORT_FAIL"DML operator Compute failed: op=Transpose HR=0x80070057" 3/3

HR=0x80070057 is E_INVALIDARG surfacing as a normal ORT status: the process survives and the
failing node is named. Still a failure — the foreign handle is a real upstream defect — but it is now
diagnosable instead of memory corruption.

Open question for reviewers

The remaining question is why a non-EP pointer reaches DecodeDataHandle at all. The
CreateAllocatorImpl comment blames the CpuAllocator stub served for GPU memtype, and I confirmed
at runtime that the fallthrough is reached. Whether that is the only source, and whether the correct
long-term answer is to stop producing the stub entirely, is worth a second opinion from whoever owns
the allocator contract.

Repro harness (parent/child so a crash is captured as an exit code rather than killing the run) is
available if useful for CI.

@danieyan-amd

Copy link
Copy Markdown
Collaborator Author

Root cause of the functional failure, traced — and it isn't what the code comment says

I instrumented a throwaway build (env-gated, since reverted) to log every CreateAllocatorImpl call,
every CpuAllocator::Alloc, and every rejected handle. This is the entire trace for an SD3.5
vae_decoder run:

[DIAG] CreateAllocatorImpl devtype=1(GPU=1) memtype=0 ep_raw=0000000000000000
[DIAG]   -> FALLTHROUGH: serving CpuAllocator stub
[DIAG] REJECT 00000269494EA760  class=never-ours  live=143 freed=108

Three things fall out of it:

  1. ORT requests a GPU / DEFAULT allocator — real VRAM, not pinned.
  2. m_ep_raw == 0. This is the registration-time call, before CreateEp.
  3. It happens exactly once. ORT never asks again, so that one answer is the session's GPU allocator.

Because m_ep_raw is null, the existing real-GPU branch and the guard I added in 3e845d6 — which
share the condition IsGpuAllocator(...) && m_ep_raw != nullptr — are both skipped, and the
fallthrough hands back a CpuAllocator.

The factory promises GPU memory and returns a CPU allocator, because it is asked before it can
answer, and nothing revisits the decision.
Graph inputs are therefore never placed in VRAM, and a
plain CPU pointer reaches DecodeDataHandle — which is exactly where the rejection lands: on the
model input latent_sample, at the very first node, Transpose_0.

What the trace rules out

Hypothesis Verdict Evidence
use-after-free no class=never-ours; freed set tracked separately (108 entries)
offset into a live allocation (base+N) no nearest live allocation is 130,496 bytes away; PluginDmlAllocationInfo is ~100 bytes
the stub's malloc pointer being decoded no CpuAllocator::Alloc called zero times
the HOST_ACCESSIBLE branch no never taken

The second row is the one that matters for this PR: it confirms an exact-match registry is the
right validation. Had the rejected pointer been an offset into a live allocation, this fix would be
wrong and would need to be range-based. It isn't.

The third row is worth flagging to whoever owns this file: the comment in CreateAllocatorImpl
"The stub returns plain malloc with no D3D12 resource, so when a GPU-resident tensor pointer …
reaches DecodeResource->DecodeDataHandle it is blind-cast … -> 0xC0000005"
— describes a mechanism
that does not occur. Nothing allocates through the stub. The stub's harm is indirect: by standing
in as ORT's GPU allocator, it prevents inputs from ever being placed on the GPU.

Two consequences for this PR

1. My dml_factory.cc change is inert. It is gated on m_ep_raw != nullptr, and the
problematic call has m_ep_raw == nullptr, so it never fires — confirmed, the rejection message
appears in no log. The crash is stopped entirely by 3e845d6's registry and 7303afa's guards.
I'm happy to drop that hunk for a minimal diff, or keep it as defence-in-depth for the
post-registration case. Reviewer's call.

2. The cure is a separate change and needs an owner. The factory already holds the ID3D12Device
and DML device, so a DmlBucketizedBufferAllocator is constructible without the EP. Options:

  • have the factory create and own the GPU allocator lazily, independent of EP lifetime; or
  • return a status that causes ORT to re-request after CreateEp, if the contract permits.

Until that lands, this PR is containment: 8 crashes eliminated, 0 regressions, 0 models restored to
working.
Models fail cleanly with a named node instead of an access violation.

Regression evidence

A simple static Conv/Relu/Add model runs clean on both shipped and patched builds (RUN OK,
correct shape, finite output) — so the registry causes no false rejections on a model that genuinely
exercises the allocator.

Separately, SD1.5 vae_decoder fails identically on both builds with
Conv '/post_quant_conv/Conv': Lazy kernel creation failed — an unrelated pre-existing defect that
the crashes were masking. Probably deserves its own issue.

@tperry-amd

Copy link
Copy Markdown
Collaborator

Is this intentionally in draft status?

@danieyan-amd

Copy link
Copy Markdown
Collaborator Author

Is this intentionally in draft status?

Yes it is, im working on a fix for this jira

@danieyan-amd

Copy link
Copy Markdown
Collaborator Author

Root cause found — it is ORT's memory-pattern planner, and there is a no-code-change workaround

Further instrumentation identified the actual mechanism. It is not what this PR's description (or the
long-standing comment in dml_factory.cc) says, so I want to correct the record before review.

The mechanism

enable_mem_pattern is on by default. ORT profiles the first execution, then from the second run
onwards allocates one large block and hands each activation tensor base + offset.

DmlBucketizedBufferAllocator::AllocImpl does not return a memory address — it returns an opaque
pointer to a C++ object
:

return allocInfo.Detach();     // PluginDmlAllocationInfo*, not a data address

Offsetting that pointer produces something that is not an object. DecodeDataHandle then decodes it
and the caller dereferences it. Straight from the trace:

[DIAG] ALLOC size=335544320 -> 0000014A66AF0880     <- ORT asks for ONE 320 MB block
[DIAG] REJECT               0000014A66B10880        <- then uses base + 0x20000

0x66B10880 − 0x66AF0880 = 0x20000 — exactly 131,072 bytes. Logging every allocation and free
confirms the rejected pointer was never issued:

REJECTED=0000014A66B10880
was it ever ALLOC'd? 0
was it ever FREEd?   0

This is why the original ticket says the app "crashes after generating an image". Run 1 records
the pattern and succeeds; run 2 applies it and dies.

Proof, and a workaround that needs no code change

Same build, same model, one session option:

enable_mem_pattern Result
true (default) iteration 0 OK → fails on iteration 1 at Transpose_0
false iterations 0,1,2 OK → RUN OK (1,3,512,512), finite

Across the model set with enable_mem_pattern = False, on both the shipped and the patched DLL:

Model SHIPPED PATCHED
SD3.5-large / SD3-medium / SD3.5-turbo vae_decoder RAN CLEAN RAN CLEAN
SD2.1 / SDXL-dream / Flux-dev vae_decoder RAN CLEAN RAN CLEAN
SD3-medium / SD1.5 text_encoder RAN CLEAN RAN CLEAN

All eight previously-crashing models run correctly on the unpatched binary. Users can be unblocked
today by disabling memory patterns, at the cost of some memory-reuse efficiency.

What this means for this PR

It is still worth merging, and it is still correct — a wild pointer dereference reachable from
ordinary model execution is a real defect, and this converts it into a diagnosable error with 0
regressions across 10 models. But it is containment, not the cure, and reviewers should read it
that way.

Two honest caveats:

  1. The dml_factory.cc hunk is inert. It is gated on m_ep_raw != nullptr, and the problematic
    call has m_ep_raw == nullptr. It never fires. Happy to drop it for a minimal diff.
  2. The exact-match registry encodes the wrong assumption. It treats "not a pointer we issued" as
    invalid, when in fact ORT may legitimately pass base + offset. A range-based lookup —
    base ≤ p < base + size mapping back to the owning allocation — would both keep the safety
    property and make offset pointers work, fixing the crash and the functional failure in one
    change.

I'd suggest (2) as the real fix and am happy to prepare it. The proper long-term answer is for the
allocator to return a pointer ORT can legally offset, rather than an object handle.

Unrelated defect found along the way

SD1.5 vae_decoder fails identically on both builds with
Conv '/post_quant_conv/Conv': Lazy kernel creation failed — pre-existing, previously masked by the
crashes. Probably deserves its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants