Skip to content

Commit 6aa93e0

Browse files
michel2323claude
andcommitted
Harden command-queue lifetime to prevent banned-context faults on free
On the LTS NEO stack (25.18) freeing a buffer does not drain queues that still have work in flight referencing it: the in-flight kernel then faults and the context is banned, surfacing later as a ZE_RESULT_ERROR_UNKNOWN at an unrelated op. global_queue is task-local, so a test file's task can also die with work still queued. - Register every global queue in a per-(context,device) registry that holds the queue strongly, keyed by a weak reference to the owning task (src/context.jl). A WeakRef to the queue would be cleared in the same GC cycle that queues its finalizer, hiding it from release exactly when its in-flight work still references buffers being freed. - Before any BLOCKING_FREE, synchronize all queues that could reference the buffer (src/pool.jl, synchronize_all_queues). - Synchronize outside the registry lock with finalizers disabled, skip already-finalized queues, and retire queues of dead tasks. - In the queue finalizer, drain (unchecked, since a banned context returns an error) then destroy, and null the handle so a concurrent synchronize_all_queues skips it (lib/level-zero/cmdqueue.jl). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9199940 commit 6aa93e0

3 files changed

Lines changed: 96 additions & 1 deletion

File tree

lib/level-zero/cmdqueue.jl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,17 @@ mutable struct ZeCommandQueue
2020
zeCommandQueueCreate(ctx, dev, desc_ref, handle_ref)
2121
obj = new(handle_ref[], ctx, dev, ordinal)
2222
finalizer(obj) do obj
23+
# the queue may still have work in flight (nothing requires a task to
24+
# synchronize before dying), and zeCommandQueueDestroy does not wait for
25+
# it: on the LTS NEO stack the still-running work then faults as soon as
26+
# a referenced allocation is freed, getting the context banned. drain the
27+
# queue first; unchecked, as sync on a banned context returns an error.
28+
unchecked_zeCommandQueueSynchronize(obj, typemax(UInt64))
2329
zeCommandQueueDestroy(obj)
30+
# mark the queue as destroyed: it can still be weakly reachable (e.g. from
31+
# the queue registry used by `synchronize_all_queues`), and synchronizing a
32+
# destroyed handle crashes in the driver.
33+
obj.handle = ze_command_queue_handle_t(C_NULL)
2434
end
2535
obj
2636
end

src/context.jl

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,84 @@ function global_queue(ctx::ZeContext, dev::ZeDevice)
224224
# NOTE: dev purposefully does not default to context() or device() to stress that
225225
# objects should track ownership, and not rely on implicit global state.
226226
get!(task_local_storage(), (:ZeCommandQueue, ctx, dev)) do
227-
ZeCommandQueue(ctx, dev; flags = oneL0.ZE_COMMAND_QUEUE_FLAG_IN_ORDER)
227+
queue = ZeCommandQueue(ctx, dev; flags = oneL0.ZE_COMMAND_QUEUE_FLAG_IN_ORDER)
228+
# disable finalizers while mutating the registry: a GC-driven finalizer on this
229+
# task could call back into `synchronize_all_queues` (the lock is reentrant) and
230+
# observe/mutate the registry mid-update.
231+
GC.enable_finalizers(false)
232+
try
233+
@lock queue_registry_lock begin
234+
push!(get!(Vector{Tuple{WeakRef,ZeCommandQueue}}, queue_registry, (ctx, dev)),
235+
(WeakRef(current_task()), queue))
236+
end
237+
finally
238+
GC.enable_finalizers(true)
239+
end
240+
queue
241+
end
242+
end
243+
244+
# Registry of all queues created through `global_queue`, across tasks. Buffers can be
245+
# freed from any task (GC finalizers), so `release` needs to be able to find the queues
246+
# that may still have work in flight referencing the buffer; queues themselves are
247+
# cached task-locally and would otherwise be unreachable from the finalizing task.
248+
#
249+
# Entries reference the queue *strongly*: the GC clears WeakRefs to a dead queue in the
250+
# same cycle that queues its finalizer, i.e., before the finalizer runs, so a WeakRef
251+
# would hide the queue from `release` exactly when its in-flight work still references
252+
# buffers about to be freed. The owning task is tracked weakly instead: queues are
253+
# task-local, so once their task is dead no new work can reach them, and the entry can
254+
# be dropped (allowing the queue to be finalized) after a final synchronize.
255+
const queue_registry_lock = ReentrantLock()
256+
const queue_registry = Dict{Tuple{ZeContext,ZeDevice},Vector{Tuple{WeakRef,ZeCommandQueue}}}()
257+
258+
# synchronize all known queues that target the given context (and device, if specified),
259+
# i.e., all queues whose in-flight work could possibly reference an allocation that is
260+
# about to be freed.
261+
function synchronize_all_queues(ctx::ZeContext, dev::Union{ZeDevice,Nothing})
262+
queues = ZeCommandQueue[]
263+
stale = Tuple{WeakRef,ZeCommandQueue}[]
264+
GC.enable_finalizers(false)
265+
try
266+
@lock queue_registry_lock begin
267+
for ((qctx, qdev), entries) in queue_registry
268+
qctx == ctx || continue
269+
(dev === nothing || qdev == dev) || continue
270+
for entry in entries
271+
(task, queue) = entry
272+
queue.handle == C_NULL && continue # finalized, handle destroyed
273+
push!(queues, queue)
274+
# entries whose task was already dead at this point cannot
275+
# receive new work, so they are safe to retire after the sync
276+
if task.value === nothing || istaskdone(task.value::Task)
277+
push!(stale, entry)
278+
end
279+
end
280+
end
281+
end
282+
# synchronize outside the lock: this can block for as long as a kernel runs,
283+
# and finalizers running concurrently also need to take the lock. Keep
284+
# finalizers disabled so none of the collected queues can be destroyed
285+
# between collection and synchronization.
286+
for queue in queues
287+
oneL0.synchronize(queue)
288+
end
289+
# retire drained queues of dead tasks, allowing them to be finalized (the
290+
# finalizer synchronizes once more before destroying the queue, in case
291+
# the queue is dropped through other means).
292+
if !isempty(stale)
293+
@lock queue_registry_lock begin
294+
for ((qctx, qdev), entries) in queue_registry
295+
qctx == ctx || continue
296+
(dev === nothing || qdev == dev) || continue
297+
filter!(entry -> !any(s -> s === entry, stale), entries)
298+
end
299+
end
300+
end
301+
finally
302+
GC.enable_finalizers(true)
228303
end
304+
return
229305
end
230306

231307
"""

src/pool.jl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ function release(buf::oneL0.AbstractBuffer)
8383
# evict(ctx, dev, buf)
8484
#end
8585

86+
# NEO (at least the 25.18 LTS release) does not honor the BLOCKING_FREE/DEFER_FREE
87+
# policies of zeMemFreeExt: it advertises ZE_extension_memory_free_policies but
88+
# unmaps the allocation immediately, even with work in flight that references it.
89+
# That turns a GC-driven free of a dead array whose last kernel/copy hasn't retired
90+
# into a GPU pagefault, which gets the kernel context banned and makes every later
91+
# submission fail with ZE_RESULT_ERROR_UNKNOWN. Synchronize the queues that could
92+
# reference this buffer before freeing.
93+
synchronize_all_queues(oneL0.context(buf), oneL0.device(buf))
94+
8695
free(buf; policy=oneL0.ZE_DRIVER_MEMORY_FREE_POLICY_EXT_FLAG_BLOCKING_FREE)
8796

8897
# TODO: queue-ordered free from non-finalizer tasks once we have

0 commit comments

Comments
 (0)