Launch Window and Fusion

When a program issues many small kernels, the host time spent on each launch can exceed the time the GPU spends running it. On HIP, OpenCL, Vulkan, CUDA and Metal, Sarek therefore holds JIT launches back until the next point where your program observes the device, merges independent ones into a single kernel launch, and on HIP also merges element-wise consumers into the kernel that produces their input. This page says what is on by default, what flushes the held launches, how to see the effect, how to turn it off, and one change you may notice: device-compile and launch errors can surface in a later call than the launch.

Defaults per backend

Feature HIP OpenCL Vulkan CUDA Metal Native, Interpreter, CPU devices
Launch window (deferral, horizontal fusion) on on on on on (WebGPU: off) never
Epilogue fusion on off off off on never

What is deferred, and what flushes

A launch is deferred when all of these hold:

Every other launch (stream launches, Execute.run_source, Soa_launch, raw Kernel launches) first issues everything that is held, then runs as before.

Held launches are issued, in an order that respects every dependency, before any of these runs:

You normally do not need to do anything: the observable result is the same as issuing each launch immediately. To make the device start earlier, call Execute.Window.flush ().

At most 8 groups of launches are held; a ninth issues the oldest first. A group fuses at most 8 launches.

Asynchronous launches on Vulkan

Independently of the window, with a batch limit above 1 (the default is 32) a Vulkan launch no longer waits for the GPU. SPOC_VULKAN_BATCH=1 is the fallback switch: GPU page faults and ring resets were once seen on RADV with the async tests (Sarek #504); a validation-layer run found no hazard and the cause is unconfirmed, so the issue stays open. It is appended to the stream’s open command buffer and returns; the buffer is submitted after SPOC_VULKAN_BATCH dispatches (default 32), when another stream of the device launches, and at any point where your program observes the device: reading or writing a vector, Transfer.flush, Device.synchronize, Stream.synchronize, destroying a device. Freeing a buffer does not submit or wait: the release is queued until the pending launches have completed. Results and their order do not change. Dispatches that depend on each other are ordered in memory by a barrier the backend records before each one.

On an RX 7900 XTX with RADV, the host cost of a launch dropped from about 59 us to 6.6 us (SPOC_VULKAN_BATCH=1 against 32; see CHANGES.md for the method and the chain numbers).

A failed submission or wait raises kernel_launch_failed naming the kernels of the batch at the first wait that observes it (a read, a synchronisation), not at the launch. SPOC_VULKAN_BATCH=1 submits and waits at every launch. Details: specs/vulkan-async-launches.md in the repository.

Error timing

Argument checks, capability checks, source generation and argument expansion run inside the run or run_vectors call, so those errors are raised where the launch is made. Device compilation and the driver launch happen when the launch is issued. A failure there is raised by whichever call flushed the window (for example Transfer.flush or Vector.get), not by the launch that caused it. The other held groups are still issued and the first exception is re-raised afterwards. One known exception: if a merged epilogue kernel fails and falls back, the producer is issued first and, if it raises, the consumer is not issued (issue #459). If the window overflows, the oldest group is issued before the new launch is accepted; if that raises, the new launch is not made. A failed deferred launch at program exit is printed on stderr.

If you need an error at the launch site while debugging, turn the window off (below).

A fused kernel that fails to build or launch does not fail your program: its members are issued one by one, and that combination is not fused again until Execute.Window.clear_fused_cache () is called.

See what happened

Execute.Window.stats () counts launches since the last reset_stats:

Field Meaning
submitted JIT launches made through run and run_vectors
issued backend launches actually made
fused_groups groups of several launches issued as one kernel
fused_built distinct fused kernels built; reset_stats does not reset it
fused_refused launch sequences whose fusion failed

If issued is lower than submitted, launches were merged. Horizontal merges also raise fused_groups; epilogue merges do not, and show only as issued lower than submitted. The example launches two kernels that read the same input and write different outputs; they can run as one launch.

open Sarek
module Device = Spoc_core.Device
module Vector = Spoc_core.Vector
module Transfer = Spoc_core.Transfer

let double =
  [%kernel
    fun (x : float32 vector) (y : float32 vector) (n : int32) ->
      let open Sarek_stdlib.Std in
      let i = global_thread_id in
      if i < n then y.(i) <- x.(i) * 2.0]

let negate =
  [%kernel
    fun (x : float32 vector) (z : float32 vector) (n : int32) ->
      let open Sarek_stdlib.Std in
      let i = global_thread_id in
      if i < n then z.(i) <- 0.0 - x.(i)]

let ir_of (_, kirc) =
  match kirc.Kirc_types.body_ir with
  | Some ir -> ir
  | None -> failwith "no IR"

let launch dev ir args n =
  Execute.run_vectors
    ~device:dev
    ~ir
    ~args
    ~block:(Execute.dims1d 256)
    ~grid:(Execute.dims1d ((n + 255) / 256))
    ()

let run_two dev =
  let n = 1024 in
  let x = Vector.create Vector.float32 n in
  let y = Vector.create Vector.float32 n in
  let z = Vector.create Vector.float32 n in
  Vector.fill x 1.0 ;
  let double_ir = ir_of double and negate_ir = ir_of negate in
  Execute.Window.reset_stats () ;
  launch dev double_ir [Vec x; Vec y; Int n] n ;
  launch dev negate_ir [Vec x; Vec z; Int n] n ;
  Transfer.flush dev ;
  let s = Execute.Window.stats () in
  Printf.printf
    "submitted=%d issued=%d fused_groups=%d y[0]=%f z[0]=%f\n"
    s.Execute.Window.submitted
    s.Execute.Window.issued
    s.Execute.Window.fused_groups
    (Vector.get y 0)
    (Vector.get z 0)

On HIP this should print submitted=2 issued=1 fused_groups=1; with the window off, issued=2. A test with a fake backend checks the mechanism (sarek/execute/test/test_launch_window.ml:378-379). Two launches are fused only if they are independent: if the second reads what the first writes, they are issued in order as two launches.

Turn it off or on

Scope How
Whole process, at start SAREK_OPT_DISABLE=window (one-way; see Environment variables)
Whole process, at run time Execute.Window.set_enabled false
One backend Execute.Window.set_backend_enabled ~backend:"HIP" false
Epilogue fusion, at start SAREK_OPT_DISABLE=epilogue (one-way)
Epilogue fusion, one backend Execute.Window.set_epilogue_backend ~backend:"HIP" false (true only undoes it; it grants nothing)

SAREK_EXPLAIN=1 prints, per kernel and device (<framework>:<id>), whether the window deferred the launch and, per producer and consumer pair, whether epilogue fusion applied (sarek-explain <kernel> <framework>:<id> window applied exact, sarek-explain <p>+<c> <framework> epilogue applied granted=cross-kernel-contraction reason="...", or refused <why>). The setters flush held launches first. The backend argument is the framework name shown by sarek-device-info (HIP, OpenCL, Vulkan, Metal, CUDA/PTX, CUDA/C). Turning the window on for a backend outside the default set (WebGPU, whose codegen is not wired) is an opt-in. Turning epilogue fusion on for OpenCL or Vulkan grants nothing: those kernels fuse only when both hold Cross_kernel_contraction, which accepts the difference described above (at most half an ulp of the product plus an ulp of the result).

let configure () =
  (* Keep launches immediate while debugging a device-compile error. *)
  Execute.Window.set_enabled false ;
  (* Or: opt Metal out, keep everything else at the defaults. *)
  Execute.Window.set_enabled true ;
  Execute.Window.set_backend_enabled ~backend:"Metal" false ;
  Printf.printf
    "window on HIP: %b, epilogue on HIP: %b\n"
    (Execute.Window.defers "HIP")
    (Execute.Window.epilogue_on "HIP")

Execute.Window.clear_fused_cache () forgets every fused kernel built so far.

When two launches fuse

Horizontal fusion merges launches that all hold:

Launches that do not qualify stay in the window and are issued in order. A launch may join an earlier held group only if it conflicts with nothing in that group or in any later one, so independent launches can be issued ahead of an unrelated launch.

Epilogue fusion merges a launch C into a held launch P when C is a one-dimensional element-wise kernel (let i = global_thread_id in ..., no barrier, atomic, shared or local array) that reads P’s output at its own index and stores only at its own index. C’s body then runs right after P’s store, in the same thread. Before merging, Sarek evaluates on the host, for these dimensions and scalar values, that P stores each element at most once and that every element C writes is one P stores. If it cannot prove that (for example an index depends on data, or a launch has more than 2^24 threads), there is no merge and nothing changes.

Explicit, caller-driven fusion of kernel IRs is a different mechanism and can delete intermediate arrays; see Kernel Fusion. The window never deletes a store.

Repeated launches

The IR record is compared by physical identity. Take it once from [%kernel ...] (as double_ir above) and reuse it: source generation, the capability check and the backend’s compile-cache key are then skipped on later launches. After you change an intrinsic registration at run time, call Execute.clear_launch_cache ().

First launch in a new process

OpenCL compiles a kernel from source the first time a process uses it, and used to do so in every process. The OpenCL backend now keeps the driver’s program binary on disk (Vulkan already kept SPIR-V), so a second process loads it instead. Measured on tartarus (RX 7900 XTX through rusticl, and the Intel OpenCL CPU runtime on a Ryzen 9 7950X; the machine was loaded, so treat the numbers as provisional), the build of a matvec, an RMS-norm and a tiled GEMM kernel took, median of 7 separate processes each:

Device From source From the disk cache
Intel OpenCL CPU 91 / 33 / 42 ms 1.6 / 1.1 / 1.2 ms
rusticl, Mesa shader cache off 7.7 / 3.8 / 5.3 ms 2.2 / 1.9 / 4.2 ms
rusticl, Mesa shader cache on (default) 1.8 / 0.8 / 0.9 ms 0.7 / 0.5 / 0.6 ms

Mesa keeps its own cache, so on rusticl the gain is small by default. Metal has no disk cache of ours: not needed, the OS shader cache already serves a new process (measured on Apple M4, macOS 15, 5 rounds, ms median, for a Q8_0 matvec / RMS norm / RoPE kernel: a source new to the machine costs 2.6-3.6 ms for the library plus 3.6-9.5 ms for the pipeline (about 7.0-12.2 ms per kernel in total), a later process at most 0.04 ms for either). The results are the same bits as a build from source. See Compile cache for the key, the fallback and SAREK_DISK_CACHE=0.

See also