Fix D3D12 device removal when zeroing read-only (shader_resource) tensors (#1079) - #1080
Fix D3D12 device removal when zeroing read-only (shader_resource) tensors (#1079)#1080nv-slang-bot[bot] wants to merge 9 commits into
Conversation
ccummingsNV
left a comment
There was a problem hiding this comment.
I don't think we can do this - we're effectively saying that a fairly innocent 'clear' function asked to clear a 1GB tensor has to:
- allocate 1GB of heap memory
- set it 0
- allocate another 1GB in the upload heap internally
- copy memory into it
- free the first 1GB
- perform buffer copy on gpu
- free the upload heap memory
All to set it 0 :)
You probably could achieve this in D3D12 by creating an aliased UAV resource and clearing that.
My preference to be honest, unless there's a real demand for this, is to raise an exception saying 'To clear a tensor it must be writable'. I don't see that as unreasonable - clearing is an inherently write-operation. If the user has allocated a read-only tensor and then asked to write to it, it's valid to call that an error.
|
You're right — thanks, that's a fair objection. The copy path turns a "clear" into a full-size host allocation + an upload-heap staging copy + a GPU copy, which is a bad trade for large tensors, and it's the wrong shape for what I'll pivot to your preferred approach: One consequence worth flagging: I'll leave the aliased-UAV-resource D3D12 trick aside unless you'd prefer it — the exception is simpler and, unless there's real demand for clearing read-only tensors, it's the right default. Pushing the revised diff shortly (revert the copy path → writable-check exception; update the repro + regression tests; rebuild + re-run VK/CUDA). 🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify. |
|
Pushed the revised approach ( As flagged,
🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify. |
ccummingsNV
left a comment
There was a problem hiding this comment.
Better, but appears to be a lot of tests that have been disabled or set to use uninitialized data, rather than just to use a writable tensor. Unless the test is explicitly designed to validate read-only tensor behaviour, it is better that we use the correct tensor type than to remove or make less robust the test
|
|
||
| void Tensor::clear(CommandEncoder* cmd) | ||
| { | ||
| // Clearing is a write. clear_buffer zeroes via a UAV, which D3D12 rejects on a buffer |
There was a problem hiding this comment.
This comment can be a lot shorter - 1 line is enough!
| with pytest.raises(RuntimeError, match="(?i)read-only|unordered_access|write"): | ||
| ro.clear() | ||
|
|
||
| # Device must still be alive after the rejected operations. |
There was a problem hiding this comment.
This 'device must still be alive check is overkill' - if every test that could kill the device did that we'd have 1000s of dummy tensors made. We should remove it from these tests.
| tensors: list[Tensor] = [] | ||
| for i in range(4): | ||
| t = Tensor.zeros( | ||
| t = Tensor.empty( |
There was a problem hiding this comment.
Any reason we don't just use a writable tensor?
There was a problem hiding this comment.
@nv-slang-bot Do you have an answer to Chris' question? I'm not necessarily asking for a change, but let's start by understanding the decision-making here.
There was a problem hiding this comment.
Short answer: these read tests deliberately exercise the read-only Tensor<float,1>[4] parameter shape, and a writable tensor can't bind to that parameter. Switching to a writable tensor would either fail to resolve (against the read-only Tensor[] param) or — if we also flipped the parameter to RWTensor[] — just duplicate the existing test_array_of_rwtensors_write. The empty() + copy_from_numpy construction is what lets us build a read-only input without tripping the very check this PR adds.
The detail (resolution paths under slangpy/builtin/):
A tensor marshall encodes its access in the Slang type name — writable → RWTensor<float,1>, read-only → Tensor<float,1> (tensor.py builds slang_type with access = read_write if writable else read). Resolution then treats the scalar and array cases differently:
- Scalar
Tensorparam (tensorcommon.py, theITensorTypebranch): only the read-only → writable direction is rejected (if bound_type.writable and not self.writable: raise "Can't pass a read-only tensor to a writable tensor"). The opposite is adapted — the result type is rebuilt withaccess = bound_type.access— so a writable tensor can bind to a read-only scalarTensorparam. - Array
Tensor[]param (array.py,ArrayMarshall.resolve_types): elements are matched byfull_namestring (st.element_type.full_name == bound_type.full_name), andarray_to_array_scalarconvertableonly adapts scalar element types / unknown-generic / unknown-resource elements — there is no access adaptation on the array element path. So a writable input array (RWTensor<float,1>[4]) doesn't match a read-onlyTensor<float,1>[4]parameter by name, and resolution fails with aResolveException.
The reverse of that mismatch has an explicit regression test in this PR — test_array_of_read_only_tensors_to_rwtensor_array_raises_cleanly asserts ... does not match slang type RWTensor<float, 1>[4] — the same name-based array matching, just in the other direction.
So for test_array_of_tensors_read / test_array_of_difftensors_read the input must be read-only to match the parameter. Before this PR the natural way to make one was Tensor.zeros(shader_resource), but that is exactly what this PR now makes raise (zeroing goes through clear(), and a clear on read-only storage is the D3D12 device-removal we're fixing). Tensor.empty(shader_resource) allocates without clearing, and copy_from_numpy is a copy — valid on read-only storage, no UAV op — so it fills the input without a clear.
If you'd prefer these two to test the writable path instead, the change is: parameter → RWTensor<float,1>[4], inputs → writable Tensor.zeros(...). That's a valid test, but it is already what test_array_of_rwtensors_write covers, and we'd lose coverage of the read-only array-parameter shape (a distinct resolution path). Happy to consolidate that way if you'd rather — otherwise the intent here was to keep the read-only array-param path under test.
🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify.
| pytest.skip( | ||
| "Array dispatch with read-only (shader_resource) Tensor input removes the D3D12 device" | ||
| ) | ||
| pytest.skip("Array of read-only DiffTensors on D3D12 pending on-device verification") |
There was a problem hiding this comment.
Can't we make this test work by making the tesnors writable - it seems like a valuable test to have.
| pytest.skip( | ||
| "Array dispatch with read-only (shader_resource) Tensor input removes the D3D12 device" | ||
| ) | ||
| pytest.skip("Array of read-only Tensors on D3D12 pending on-device verification") |
There was a problem hiding this comment.
Why not just make tensor writable below + let this test work?
- Shorten the Tensor::clear() comment to one line.
- Remove the redundant device-survival ('survivor tensor') assertions from the
clear/resolution regression tests (overkill; would proliferate dummy tensors).
- Read repro tests (array_of_tensors_read, array_of_difftensors_read): drop the
D3D12 skip now that read-only inputs use empty() (no clear at creation), and
document in one line why they keep read-only inputs — a writable RWTensor input
does not resolve against the read-only Tensor[]/DiffTensor[] parameter.
|
Addressed the round-2 comments (pushed
Note this is exactly the "explicitly designed to validate read-only tensor behaviour" case your review body carves out, so
🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify. |
…1079) Cover two behaviours for read-only (shader_resource, no UAV) tensors: - Tensor.zeros with shader_resource-only usage must zero-init without a UAV clear that removes the D3D12 device. - Binding a read-only tensor to a writable RWTensor parameter must raise a clean Python exception and leave the device usable.
Tensor::clear() unconditionally issued clear_buffer, which the D3D12 RHI implements via a UAV (ClearUnorderedAccessViewUint). A tensor whose storage was created shader_resource-only (no unordered_access) has no UAV, so the clear failed CreateUnorderedAccessView and removed the device, poisoning the shared D3D12 worker. Vulkan and CUDA clear via transfer ops and were unaffected. When the storage buffer lacks the unordered_access flag, zero it by uploading a host-side zero-filled staging copy (a copy-path upload, no UAV required) instead of clear_buffer. The UAV fast path is unchanged for the common case. This covers Tensor.zeros, zeros_like, and with_grads grad-buffer clears, which all funnel through Tensor::clear().
The scalar read-only->writable guard is already covered by resolve_types. The repro is array-shaped and the array path resolves element types by name (Tensor vs RWTensor) with no access-adaptation, so add the decisive case: a list of shader_resource-only tensors bound to RWTensor<float,1>[4] must raise cleanly and leave the device usable.
…1079) Address independent review of the D3D12 read-only clear fix: - When Tensor::clear() is given a command encoder, route the zero-upload through it (cmd->upload_buffer_data) so the clear stays ordered with other commands recorded on that encoder, matching the UAV clear_buffer path. Without an encoder, keep the immediate set_data upload. - Strengthen the regression tests: prefill nonzero data before clear() so the test proves the storage is actually zeroed (not just newly-allocated), and match the specific resolution diagnostics (scalar vs array) instead of a bare Exception.
…ests Per critique: make the read-only->writable tests assert the specific ResolveException (not a bare Exception, which could accept unrelated backend failures), and reduce docstrings to concise behavioral intent with a single issue reference rather than change-history narration.
…branch (#1079) Peer review found the UAV-clear requirement is D3D12-specific; gating the copy fallback on the unordered_access bit alone changed behaviour for non-device_local (read_back/upload) tensors, where clear_buffer previously succeeded on CUDA/Metal. Restrict the copy fallback to device_local storage - the bug's actual surface and the only memory type guaranteed copy_destination usage - so other memory types keep the original clear_buffer path (zero behaviour change). Add a test exercising clear() through a caller-supplied command encoder, which was previously uncovered.
…ng (#1079) Per maintainer review (ccummingsNV): the copy-path zeroing of read-only storage was too expensive (full-size host allocation + upload-heap staging + GPU copy per clear). Clearing is inherently a write, so Tensor::clear() now raises when its storage lacks unordered_access, rejecting the misuse before any GPU op. This still fixes the D3D12 device removal (no invalid UAV clear is issued) and directly satisfies the issue's second ask (clean exception, not device loss). Consequence: Tensor.zeros/zeros_like on read-only (shader_resource-only) storage now raise too. The read repro tests build their read-only inputs via Tensor.empty + copy_from_numpy (the zero-init was overwritten anyway), and the regression tests assert the clean raise + device survival.
- Shorten the Tensor::clear() comment to one line.
- Remove the redundant device-survival ('survivor tensor') assertions from the
clear/resolution regression tests (overkill; would proliferate dummy tensors).
- Read repro tests (array_of_tensors_read, array_of_difftensors_read): drop the
D3D12 skip now that read-only inputs use empty() (no clear at creation), and
document in one line why they keep read-only inputs — a writable RWTensor input
does not resolve against the read-only Tensor[]/DiffTensor[] parameter.
f9b1b14 to
3d50ffc
Compare
|
CI triage for the This is a flaky unit test, not a build failure and unrelated to this PR's change. The build itself succeeded; the job failed on a single C++ doctest assertion (1 of 20,980): Signals that point to a flake rather than a regression:
I've re-run the failed job. If it recurs, the flake likely warrants its own tracking issue against the profiler tests (same family as #1077) rather than a change here. 🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify. |
Summary
Tensor,RWTensor,DiffTensor,RWDiffTensor#1078 (which adds the repro tests).Tensor::clear()issuing a UAV zeroing op that D3D12 rejects (removing the device) on a buffer created without theunordered_accessflag. Per maintainer review, fixed by rejecting the misuse:clear()now raises a clean exception on read-only storage (clearing is a write and requires a writable buffer), so no invalid UAV op is issued.The bug
Tensor::clear()(src/sgl/func/tensor.cpp) calledclear_buffer()unconditionally. On the D3D12 RHI backendclear_bufferis implemented via a UAV (ClearUnorderedAccessViewUint), which requires the buffer to have been created with theunordered_accessusage flag. ATensorcreated withusage=BufferUsage.shader_resourceonly (no UAV) — which is the correct, required usage for read-only tensor arrays — therefore failedCreateUnorderedAccessView, triggeringRemoveDevice(DXGI_ERROR_INVALID_CALL) and poisoning the entire shared D3D12 device. Vulkan and CUDA implement the clear via transfer ops (no UAV requirement), which is why the failure was D3D12-specific.Tensor::clear()is reached byTensor.zeros,Tensor.zeros_like, andwith_gradsgradient-buffer zeroing, so a guard at that single method covers all three paths.Note: adding the
unordered_accessbit to make the clear succeed is not a valid fix — it flips the tensor's derived writability, renaming its element typeTensor<...>→RWTensor<...>, which breaks array type-resolution (element types are compared by full-name string).The fix
Tensor::clear()now raises a clean exception when its storage lacksunordered_access:Clearing is inherently a write, so requiring a writable buffer is the natural contract, and rejecting the misuse before any GPU work means the invalid UAV op is never issued — the D3D12 device is no longer removed. This also directly satisfies the issue's second ask (a read-only-tensor misuse should surface as a clean exception, not a device loss). The approach follows maintainer review on the PR (a copy-path zeroing alternative was rejected as too expensive — a full-size host allocation + upload-heap staging + GPU copy per clear).
Consequence:
Tensor.zeros(usage=shader_resource)/zeros_likeon read-only storage now raise too (consistent with "zeroing is a write"). A read-only tensor that needs initial contents is built withTensor.empty(...)+copy_from_numpy(...)(a copy, not a UAV op — valid on read-only storage).Tests
slangpy/tests/slangpy_tests/test_array.py:test_clear_read_only_tensor_raises_not_removes_device—Tensor.zeros(shader_resource)raises cleanly (not a device loss), and the device stays usable afterward.test_clear_writable_tensor_via_command_encoder—clear(cmd)on a writable tensor zeroes it through a caller-supplied encoder (the ordered path).test_read_only_tensor_to_writable_param_raises_cleanly/test_array_of_read_only_tensors_to_rwtensor_array_raises_cleanly— read-only→writable binding is rejected at resolution with a cleanResolveException(scalar and array); device stays alive.test_array_of_tensors_read,test_array_of_difftensors_read) construct their read-only inputs viaTensor.empty(shader_resource)+copy_from_numpy(the previouszeroszero-init was overwritten anyway).All pass on Vulkan + CUDA.
pre-commit runclean.Review history
An earlier revision of this PR zeroed read-only storage via a copy-path upload; maintainer review (@ccummingsNV) flagged that as too expensive for large tensors and preferred raising an exception, which this revision implements. Also peer-reviewed by a coworker + independent codex passes.
Scope
Defect 1 of #1079 (Metal wrong results for arrays-of-tensors and tensor-in-struct) is a separate root cause (likely upstream Slang Metal codegen for the plain-array subset; possibly slangpy marshalling / PR #1045 for the tensor-in-struct subset) and is intentionally not addressed here. #1079 should remain open until both backends are green and the per-backend skips in
test_array.pyare re-enabled.🤖 Generated by an automated SlangPy coworker — may be inaccurate. A human maintainer should verify.