SYCL: fix multi-GPU system RAM exhaustion by using Level Zero allocations#21597
SYCL: fix multi-GPU system RAM exhaustion by using Level Zero allocations#21597PMZFX wants to merge 5 commits intoggml-org:masterfrom
Conversation
…ions Replace sycl::malloc_device with zeMemAllocDevice for GPU memory allocation in the SYCL backend. sycl::malloc_device triggers the xe kernel driver's DMA-buf/TTM path which mirrors every VRAM allocation 1:1 in system RAM. zeMemAllocDevice uses the SVM/P2P path with no host staging. On a dual Intel Arc Pro B70 system (64GB VRAM, 64GB RAM), a 15.6 GiB model consumed 60 GiB of system RAM via sycl::malloc_device, causing OOM crashes. With zeMemAllocDevice, the same workload uses ~6.7 GiB of system RAM with no performance regression. All Level Zero calls include automatic fallback to the original SYCL allocation path if Level Zero interop is unavailable.
arthw
left a comment
There was a problem hiding this comment.
- don't use try .. cache in malloc/free memory function.
It will add more cost.
Just check the return value and call backup function.
arthw
left a comment
There was a problem hiding this comment.
I will test it on windows.
I will feedback the result.
Thank you!
ggml/src/ggml-sycl/common.cpp
Outdated
| SYCL_CHECK( | ||
| CHECK_TRY_ERROR(sycl::free(extra->data_device[i], *(streams[i])))); | ||
| bool freed = false; | ||
| try { |
There was a problem hiding this comment.
Use new function to replace the duplicated code to free memory.
Handle the result by SYCL_CHECK(CHECK_TRY_ERROR()) which print out stack info.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
|
|
||
| static void ggml_sycl_free_device(void *ptr, sycl::queue &q) { | ||
| if (!ptr) return; | ||
| try { |
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
|
|
||
| static void dev2dev_memcpy(sycl::queue &q_dst, sycl::queue &q_src, void *ptr_dst, | ||
| const void *ptr_src, size_t size) { | ||
| try { |
There was a problem hiding this comment.
The legacy code support memcpy between iGPU and dGPU.
System API only support between dGPUs.
So, check the dev's type before call ze API.
In case that dGPU to dGPU, use the new code.
Remove try... catch which is expensive.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| void * dev_ptr; | ||
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)sycl::malloc_device( | ||
| size, *stream))); | ||
| void * dev_ptr = ggml_sycl_malloc_device(size, *stream); |
There was a problem hiding this comment.
add SYCL_CHECK(CHECK_TRY_ERROR())
There was a problem hiding this comment.
still need add SYCL_CHECK(CHECK_TRY_ERROR() to print out the call stack when crash.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| */ | ||
| SYCL_CHECK(CHECK_TRY_ERROR(buf = (char *)sycl::malloc_device( | ||
| size, *stream))); | ||
| char * buf = (char *)ggml_sycl_malloc_device(size, *stream); |
There was a problem hiding this comment.
add SYCL_CHECK(CHECK_TRY_ERROR())
There was a problem hiding this comment.
still need SYCL_CHECK(CHECK_TRY_ERROR()
|
@PMZFX |
… deduplicate - Remove try/catch from malloc/free/memcpy helpers, check backend and device type upfront instead (ggml_sycl_is_level_zero, ggml_sycl_is_dgpu) - Move shared helpers (is_level_zero, is_dgpu, free_device) to common.cpp and declare in common.hpp to eliminate code duplication - Use SYCL_CHECK(CHECK_TRY_ERROR()) for fallback sycl::free calls - Guard dev2dev_memcpy L0 path to dGPU-to-dGPU only, preserving the host-staged path for iGPU-to-dGPU transfers - Add Windows Level Zero SDK path detection (LEVEL_ZERO_V1_SDK_PATH) in CMakeLists.txt (co-authored with @arthw)
|
@arthw Thanks for the thorough review. I've pushed a follow-up commit addressing your feedback:
Let me know if anything else needs attention. |
arthw
left a comment
There was a problem hiding this comment.
Because this PR involve the level zero API firstly. There are more issues to be considered.
- Build level zero API need to install the GPU driver (level zero running-time) in building server. In some CI, the building server is pure CPU(Xeon) machine. That will break the building of level zero API.
- Some SYCL memory features are on the way. like SYCL graph and SVM. These feature still need SYCL memory API.
- SYCL memory API is based on level zero memory API. Skip SYCL to call level zero API will lose some benefit of SYCL code.
Suggestion:
-
define building parameter: GGML_SYCL_SUPPORT_LEVEL_ZERO in ggml/CMakeLists.txt
refer to GGML_SYCL_GRAPH.
default value is "ON" -
In code, use this macro (GGML_SYCL_SUPPORT_LEVEL_ZERO) to screen the all level-zero code/include. So that if it's off, the code can be built without installing level zero lib and headers.
-
Define an ENV variable GGML_SYCL_ENABLE_LEVEL_ZERO in ggml-sycl.cpp, like GGML_SYCL_DISABLE_GRAPH. It will control in running time.
-
SYCL backend memory APIs include two sub functions: SYCL and Level Zero.
If GGML_SYCL_SUPPORT_LEVEL_ZERO = ON, it includes two branchs: SYCL and Level Zero. GGML_SYCL_ENABLE_LEVEL_ZERO is used to control the branch in running time.
If GGML_SYCL_SUPPORT_LEVEL_ZERO = OFF, it includes one branchs: SYCL in code level.
So, it won't appear that mix SYCL and Level Zero memory API usage in a session: only one style APIs are used. If malloc is fault, the code won't switch to another API. -
SYCL.md should be updated to guide for above new parameters and dependence of Intel GPU driver installation to build for level zero API usage.
How do you think?
Thank you!
ggml/src/ggml-sycl/dpct/helper.hpp
Outdated
|
|
||
| static inline void *dpct_malloc(size_t size, sycl::queue &q) | ||
| { | ||
| try { |
There was a problem hiding this comment.
- remove try... catch.
- This code is duplicated with ggml-sycl.cpp. Suggest defining new function for ze memory.
ggml/src/ggml-sycl/common.cpp
Outdated
| return sycl_down_blk_size; | ||
| } | ||
|
|
||
| bool ggml_sycl_is_level_zero(sycl::queue &q) { |
There was a problem hiding this comment.
SYCL backend is designed to run on level-zero only.
No need to check the level-zero running time here.
ggml/src/ggml-sycl/common.cpp
Outdated
| return q.get_backend() == sycl::backend::ext_oneapi_level_zero; | ||
| } | ||
|
|
||
| bool ggml_sycl_is_dgpu(sycl::queue &q) { |
There was a problem hiding this comment.
Suggest to save the hardware info in initial stage.
Refer to:
ggml-sycl.cpp:
info.devices[i].smpbo = prop.get_local_mem_size();
common.hpp:
struct sycl_device_info {
size_t smpbo;
...
}
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| // via xe_gem_prime_export, consuming system RAM equal to VRAM allocated. | ||
| // zeMemAllocDevice uses the SVM/P2P path with no host staging. | ||
| static void * ggml_sycl_malloc_device(size_t size, sycl::queue &q) { | ||
| if (ggml_sycl_is_level_zero(q) && ggml_sycl_is_dgpu(q)) { |
There was a problem hiding this comment.
define the malloc/free memory by ze into new functions.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| const void *ptr_src, size_t size) { | ||
| // Use Level Zero direct copy for dGPU-to-dGPU transfers. | ||
| // The legacy host-staged path supports iGPU-to-dGPU copies. | ||
| if (ggml_sycl_is_level_zero(q_dst) && ggml_sycl_is_dgpu(q_dst) && ggml_sycl_is_dgpu(q_src)) { |
There was a problem hiding this comment.
no need to check the level zero.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| void * dev_ptr; | ||
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)sycl::malloc_device( | ||
| size, *stream))); | ||
| void * dev_ptr = ggml_sycl_malloc_device(size, *stream); |
There was a problem hiding this comment.
still need add SYCL_CHECK(CHECK_TRY_ERROR() to print out the call stack when crash.
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| */ | ||
| SYCL_CHECK(CHECK_TRY_ERROR(buf = (char *)sycl::malloc_device( | ||
| size, *stream))); | ||
| char * buf = (char *)ggml_sycl_malloc_device(size, *stream); |
There was a problem hiding this comment.
still need SYCL_CHECK(CHECK_TRY_ERROR()
Implements the architecture suggested by @arthw: compile-time and runtime flags to cleanly separate Level Zero and SYCL memory API paths. - Add GGML_SYCL_SUPPORT_LEVEL_ZERO cmake option (default ON). All Level Zero code is wrapped in #ifdef so the build works on systems without the Level Zero SDK installed (e.g. CPU-only CI servers). Both the loader library and headers are checked before enabling. - Add GGML_SYCL_ENABLE_LEVEL_ZERO runtime env var (default 1). Controls whether Level Zero or SYCL memory APIs are used. Only one API style is used per session, no mixing. If Level Zero is enabled but the devices don't support the Level Zero backend, it auto-disables with a warning. - Remove Level Zero code from dpct_malloc. It was unused (dpct::device_memory is not called anywhere in the backend) and used try/catch for flow control. - Update SYCL.md with documentation for both new parameters. Tested on Intel Arc Pro B70 (32GB), single-GPU and dual-GPU, with both GGML_SYCL_SUPPORT_LEVEL_ZERO=ON and OFF builds. AI-assisted development (Claude). Code reviewed and tested on my hardware.
|
@arthw Thanks for the additional suggestions on the build/runtime flag architecture. Pushed a new commit implementing your approach:
Tested with both |
|
Will this need a docs update with the new build variable? |
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| dev_ptr = ggml_sycl_malloc_device(size, *stream); | ||
| #else | ||
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)sycl::malloc_device(size, *stream))); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| dev_ptr = ggml_sycl_malloc_device(size, *stream); | |
| #else | |
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)sycl::malloc_device(size, *stream))); | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)ggml_sycl_malloc_device(size, *stream))); |
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| buf = (char *)ggml_sycl_malloc_device(size, *stream); | ||
| #else | ||
| SYCL_CHECK(CHECK_TRY_ERROR(buf = (char *)sycl::malloc_device(size, *stream))); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| buf = (char *)ggml_sycl_malloc_device(size, *stream); | |
| #else | |
| SYCL_CHECK(CHECK_TRY_ERROR(buf = (char *)sycl::malloc_device(size, *stream))); | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(dev_ptr = (void *)ggml_sycl_malloc_device(size, *stream))); |
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| ptr = ggml_sycl_malloc_device(look_ahead_size, *qptr); | ||
| #else | ||
| SYCL_CHECK(CHECK_TRY_ERROR(ptr = (void *)sycl::malloc_device(look_ahead_size, *qptr))); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| ptr = ggml_sycl_malloc_device(look_ahead_size, *qptr); | |
| #else | |
| SYCL_CHECK(CHECK_TRY_ERROR(ptr = (void *)sycl::malloc_device(look_ahead_size, *qptr))); | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(ggml_sycl_malloc_device(look_ahead_size, *qptr))); |
ggml/src/ggml-sycl/ggml-sycl.cpp
Outdated
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| ggml_sycl_free_device(ptr, *qptr); | ||
| #else | ||
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(ptr, *qptr))); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| ggml_sycl_free_device(ptr, *qptr); | |
| #else | |
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(ptr, *qptr))); | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(ggml_sycl_free_device(ptr, *qptr))); |
| option(GGML_SYCL "ggml: use SYCL" OFF) | ||
| option(GGML_SYCL_F16 "ggml: use 16 bit floats for sycl calculations" OFF) | ||
| option(GGML_SYCL_GRAPH "ggml: enable graphs in the SYCL backend" ON) | ||
| option(GGML_SYCL_SUPPORT_LEVEL_ZERO "ggml: use Level Zero for device memory in SYCL" ON) |
There was a problem hiding this comment.
| option(GGML_SYCL_SUPPORT_LEVEL_ZERO "ggml: use Level Zero for device memory in SYCL" ON) | |
| option(GGML_SYCL_SUPPORT_LEVEL_ZERO "ggml: use Level Zero API in SYCL backend" ON) |
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| extern int g_ggml_sycl_enable_level_zero; | ||
| void ggml_sycl_free_device(void *ptr, sycl::queue &q); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| extern int g_ggml_sycl_enable_level_zero; | |
| void ggml_sycl_free_device(void *ptr, sycl::queue &q); | |
| #endif | |
| extern int g_ggml_sycl_enable_level_zero; | |
| void ggml_sycl_free_device(void *ptr, sycl::queue &q); |
ggml/src/ggml-sycl/common.cpp
Outdated
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| ggml_sycl_free_device(extra->data_device[i], *(streams[i])); | ||
| #else | ||
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(extra->data_device[i], *(streams[i])))); | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| ggml_sycl_free_device(extra->data_device[i], *(streams[i])); | |
| #else | |
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(extra->data_device[i], *(streams[i])))); | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(ggml_sycl_free_device(extra->data_device[i], *(streams[i])))); |
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| void ggml_sycl_free_device(void *ptr, sycl::queue &q) { | ||
| if (!ptr) return; | ||
| if (g_ggml_sycl_enable_level_zero) { | ||
| auto ze_ctx = sycl::get_native<sycl::backend::ext_oneapi_level_zero>(q.get_context()); | ||
| zeMemFree(ze_ctx, ptr); | ||
| return; | ||
| } | ||
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(ptr, q))); | ||
| } | ||
| #endif | ||
|
|
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| void ggml_sycl_free_device(void *ptr, sycl::queue &q) { | |
| if (!ptr) return; | |
| if (g_ggml_sycl_enable_level_zero) { | |
| auto ze_ctx = sycl::get_native<sycl::backend::ext_oneapi_level_zero>(q.get_context()); | |
| zeMemFree(ze_ctx, ptr); | |
| return; | |
| } | |
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(ptr, q))); | |
| } | |
| #endif | |
| void ggml_sycl_free_device(void *ptr, sycl::queue &q) { | |
| if (!ptr) return; | |
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| if (g_ggml_sycl_enable_level_zero) { | |
| auto ze_ctx = sycl::get_native<sycl::backend::ext_oneapi_level_zero>(q.get_context()); | |
| zeMemFree(ze_ctx, ptr); | |
| return; | |
| } | |
| #endif | |
| SYCL_CHECK(CHECK_TRY_ERROR(sycl::free(ptr, q))); | |
| return; | |
| } |
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | ||
| #include <sycl/backend.hpp> | ||
| #include <level_zero/ze_api.h> | ||
| #endif |
There was a problem hiding this comment.
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| #include <sycl/backend.hpp> | |
| #include <level_zero/ze_api.h> | |
| #endif | |
| #include <sycl/backend.hpp> | |
| #ifdef GGML_SYCL_SUPPORT_LEVEL_ZERO | |
| #include <level_zero/ze_api.h> | |
| #endif |
| if (GGML_SYCL_SUPPORT_LEVEL_ZERO) | ||
| message(STATUS "GGML_SYCL_SUPPORT_LEVEL_ZERO enabled") |
There was a problem hiding this comment.
| if (GGML_SYCL_SUPPORT_LEVEL_ZERO) | |
| message(STATUS "GGML_SYCL_SUPPORT_LEVEL_ZERO enabled") | |
| message(STATUS "GGML_SYCL_SUPPORT_LEVEL_ZERO ${GGML_SYCL_SUPPORT_LEVEL_ZERO}") | |
| if (GGML_SYCL_SUPPORT_LEVEL_ZERO) |
Move ggml_sycl_malloc_device to common.cpp alongside ggml_sycl_free_device. Both functions are now unconditionally available — Level Zero code is #ifdef'd inside the functions, not at call sites. All call sites use uniform SYCL_CHECK(CHECK_TRY_ERROR()) wrapping with no #ifdef blocks. Addresses arthw's review: wrap all malloc/free in SYCL_CHECK for stack traces on failure, eliminate duplicated #ifdef/else patterns at 6 call sites (-29 lines net). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yes, the SYCL.md is updated to add the discription. |
There was a problem hiding this comment.
Because the level zero lib is mandatory part of build system by default.
The CI (compile) of SYCL need to install the level zero lib for windows and Ubuntu.
Please update in .github/workflows/build.yml.
Refer to the installation of Intel GPU driver of Windows/Ubuntu.
Here is the example code for reference:
Ubuntu:
wget -qO - https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | sudo gpg --dearmor --output /usr/share/keyrings/oneapi-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" | sudo tee /etc/apt/sources.list.d/oneAPI.list
sudo apt-get update
sudo apt-get install -y level-zero level-zero-devel intel-level-zero-gpu
Windows:
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/oneapi-src/level-zero/releases/latest"
$asset = $release.assets | Where-Object { $_.name -like "level-zero-win-sdk*.zip" } | Select-Object -First 1
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile "level-zero-win-sdk.zip"
Expand-Archive -Path "level-zero-win-sdk.zip" -DestinationPath "C:\level-zero-sdk" -Force
# Set environment variables for the build (MSVC / CMake)
echo "LEVEL_ZERO_INCLUDE_DIR=C:\level-zero-sdk\include" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "LEVEL_ZERO_LIBRARY_DIR=C:\level-zero-sdk\lib" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "C:\level-zero-sdk\lib" | Out-File -FilePath $env:GITHUB_PATH -Append # if needed for runtime DLL
|
Thanks for the guidance and the Windows CI examples, I'll get that updated! |
3c0a1da to
d145fc5
Compare
Add Level Zero SDK installation to Ubuntu and Windows SYCL CI jobs so the Level Zero code path is compiled and tested in CI. Fix two bugs found during extended dual-GPU testing (no ONEAPI_DEVICE_SELECTOR set): - The Level Zero backend check was iterating all SYCL devices including CPU. The OpenCL CPU device caused Level Zero to be disabled for the GPUs, defeating the fix on multi-GPU systems. Added is_gpu() filter so only GPU devices are checked. - sycl_ext_malloc_device/sycl_ext_free (tensor reorder temp buffers) were still calling sycl::malloc/sycl::free directly, bypassing the Level Zero path. Routed through ggml_sycl_malloc_device/free_device for consistency with the other device memory call sites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d145fc5 to
c474bba
Compare
|
Pushed the CI update for Level Zero SDK installation (Ubuntu and Windows), and two additional fixes found during extended dual-GPU testing (no
Tested all configurations on dual B70: L0 on (single and dual GPU), L0 off via env var, and |
Summary
sycl::malloc_devicewithzeMemAllocDevicefor GPU memory allocation in the SYCL backendsycl::freewithzeMemFreefor corresponding deallocationsdev2dev_memcpywith direct Level Zero cross-device copyze_loaderfor Level Zero API accessProblem
On Intel multi-GPU systems,
sycl::malloc_devicetriggers the xe kernel driver's DMA-buf/TTM export path (xe_gem_prime_export->ttm_pool_alloc_page), which creates a 1:1 mirror of every VRAM allocation in system RAM. This causes system RAM to scale linearly with total VRAM allocated across GPUs, leading to OOM crashes during multi-GPU inference even when models fit entirely in VRAM.Measured on dual Intel Arc Pro B70 (32GB each, 64GB total VRAM) with 64GB system RAM:
sycl::malloc_device4 GiB = +4,112 MiB system RAM (1:1 mirror)zeMemAllocDevice4 GiB = +8 MiB system RAM (no mirror)A 15.6 GiB Q4_K_M model consumed 60 GiB of system RAM during dual-GPU inference with
sycl::malloc_device, causing repeated OOM crashes.Solution
zeMemAllocDeviceallocates GPU memory through Level Zero's SVM/P2P path instead of the DMA-buf/TTM path, avoiding the host memory staging entirely. SYCL kernels can readzeMemAllocDevicepointers with full interop, no compatibility issues.Changes:
ggml_sycl_malloc_device()/ggml_sycl_free_device()helpers that try Level Zero first, fall back to SYCLdpct_mallochelper with same Level Zero pathrelease_extra_gpuwithzeMemFreedev2dev_memcpyto usezeCommandListAppendMemoryCopyfor direct cross-device transfersTest results
Dual Intel Arc Pro B70 (32GB each), AMD Ryzen 5 9600X, 64GB DDR5, Ubuntu 26.04, kernel 7.0, compute-runtime 26.09. Model: Qwen3.5-27B.
Q4_K_M, 48K context, dual GPU (
-sm layer):Q8_0, 32K context, dual GPU: 915 t/s, system RAM flat.
Single GPU: No regression. 467 t/s pp512, 17.12 tg128.
Correctness: Output is byte-for-byte identical between single and dual GPU with same seed (verified Q4_K_M, Q6_K).
Test plan