Skip to content

Commit 1abfeb7

Browse files
committed
[UR][L0V2] Implement host-mediated buffer migration fallback
When two devices lack P2P access, getDevicePtr() was unconditionally throwing UR_RESULT_ERROR_UNSUPPORTED_FEATURE instead of migrating the buffer through a temporary host allocation. Implement the fallback: copy active-device -> host -> target device, then update activeAllocationDevice. Resumes work on #22010 Issue: https://jira.devtools.intel.com/browse/URT-1171
1 parent 00ea106 commit 1abfeb7

5 files changed

Lines changed: 280 additions & 16 deletions

File tree

unified-runtime/source/adapters/level_zero/v2/memory.cpp

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,32 @@ void *ur_discrete_buffer_handle_t::allocateOnDevice(ur_device_handle_t hDevice,
251251
return ptr;
252252
}
253253

254+
void *ur_discrete_buffer_handle_t::ensureDeviceAlloc(ur_device_handle_t hDevice,
255+
size_t size) {
256+
assert(hDevice);
257+
258+
auto id = hDevice->Id.value();
259+
if (void *existing = deviceAllocations[id].get()) {
260+
return existing;
261+
}
262+
263+
// Allocate without touching activeAllocationDevice; the caller is
264+
// responsible for updating it at the correct point in the migration flow.
265+
void *ptr;
266+
UR_CALL_THROWS(hContext->getDefaultUSMPool()->allocate(
267+
hContext, hDevice, nullptr, UR_USM_TYPE_DEVICE, size, &ptr));
268+
269+
deviceAllocations[id] =
270+
usm_unique_ptr_t(ptr, [hContext = this->hContext](void *ptr) {
271+
auto ret = hContext->getDefaultUSMPool()->free(ptr);
272+
if (ret != UR_RESULT_SUCCESS) {
273+
UR_LOG(ERR, "Failed to free device memory: {}", ret);
274+
}
275+
});
276+
277+
return ptr;
278+
}
279+
254280
ur_result_t
255281
ur_discrete_buffer_handle_t::migrateBufferTo(ur_device_handle_t hDevice,
256282
void *src, size_t size) {
@@ -340,8 +366,8 @@ void *ur_discrete_buffer_handle_t::getActiveDeviceAlloc(size_t offset) {
340366

341367
void *ur_discrete_buffer_handle_t::getDevicePtr(
342368
ur_device_handle_t hDevice, device_access_mode_t /*access*/, size_t offset,
343-
size_t /*size*/, ze_command_list_handle_t /*cmdList*/,
344-
wait_list_view & /*waitListView*/) {
369+
size_t /*size*/, ze_command_list_handle_t cmdList,
370+
wait_list_view &waitListView) {
345371
TRACK_SCOPE_LATENCY("ur_discrete_buffer_handle_t::getDevicePtr");
346372

347373
if (!activeAllocationDevice) {
@@ -366,12 +392,76 @@ void *ur_discrete_buffer_handle_t::getDevicePtr(
366392
activeAllocationDevice) != p2pDevices.end();
367393

368394
if (!p2pAccessible) {
369-
// TODO: migrate buffer through the host
370-
UR_LOG(WARN,
371-
"p2p is not accessible: requesting device ptr:{} cannot access "
372-
"allocation on device ptr:{}",
373-
(void *)hDevice, (void *)activeAllocationDevice);
374-
throw UR_RESULT_ERROR_UNSUPPORTED_FEATURE;
395+
// P2P is not accessible between the two devices; migrate through the host.
396+
UR_LOG(DEBUG,
397+
"p2p is not accessible, migrating buffer through host: "
398+
"src device ptr:{} -> dst device ptr:{}",
399+
(void *)activeAllocationDevice, (void *)hDevice);
400+
401+
auto bufferSize = getSize();
402+
403+
// Allocate a USM HOST staging buffer for the migration.
404+
void *hostBuf = nullptr;
405+
UR_CALL_THROWS(hContext->getDefaultUSMPool()->allocate(
406+
hContext, nullptr, nullptr, UR_USM_TYPE_HOST, bufferSize, &hostBuf));
407+
usm_unique_ptr_t hostBufPtr(
408+
hostBuf, [hContext = this->hContext](void *ptr) {
409+
auto ret = hContext->getDefaultUSMPool()->free(ptr);
410+
if (ret != UR_RESULT_SUCCESS) {
411+
UR_LOG(ERR, "Failed to free migration staging buffer: {}", ret);
412+
}
413+
});
414+
415+
if (cmdList) {
416+
// Order the migration relative to both the explicit wait events and any
417+
// in-flight work already on the destination command list, then drain it
418+
// so the host can safely read from the source device.
419+
if (waitListView.num > 0) {
420+
ZE2UR_CALL_THROWS(zeCommandListAppendWaitOnEvents,
421+
(cmdList, waitListView.num, waitListView.handles));
422+
}
423+
ZE2UR_CALL_THROWS(zeCommandListHostSynchronize, (cmdList, UINT64_MAX));
424+
waitListView.clear();
425+
426+
// The destination device's command list cannot access source device
427+
// memory (P2P is not available), so use the source device's own
428+
// synchronous command list for the device->host copy.
429+
UR_CALL_THROWS(synchronousZeCopy(hContext, activeAllocationDevice,
430+
hostBuf, getActiveDeviceAlloc(),
431+
bufferSize));
432+
433+
// Use ensureDeviceAlloc instead of allocateOnDevice: the latter has a
434+
// side-effect of setting activeAllocationDevice = hDevice immediately,
435+
// before the copy is enqueued. activeAllocationDevice must only be
436+
// updated after the copy is successfully complete (see below).
437+
void *dstDevPtr = ensureDeviceAlloc(hDevice, bufferSize);
438+
439+
// Host memory is accessible by all devices; enqueue the host->dest
440+
// copy on the provided command list.
441+
ZE2UR_CALL_THROWS(
442+
zeCommandListAppendMemoryCopy,
443+
(cmdList, dstDevPtr, hostBuf, bufferSize, nullptr, 0, nullptr));
444+
445+
// Drain the command list so the staging buffer is fully consumed and
446+
// can be freed immediately when hostBufPtr goes out of scope.
447+
ZE2UR_CALL_THROWS(zeCommandListHostSynchronize, (cmdList, UINT64_MAX));
448+
} else {
449+
// Synchronous fallback when no command list is available
450+
// (e.g. urMemGetNativeHandle).
451+
for (uint32_t i = 0; i < waitListView.num; i++) {
452+
ZE2UR_CALL_THROWS(zeEventHostSynchronize,
453+
(waitListView.handles[i], UINT64_MAX));
454+
}
455+
waitListView.clear();
456+
457+
UR_CALL_THROWS(synchronousZeCopy(hContext, activeAllocationDevice,
458+
hostBuf, getActiveDeviceAlloc(),
459+
bufferSize));
460+
UR_CALL_THROWS(migrateBufferTo(hDevice, hostBuf, bufferSize));
461+
}
462+
463+
activeAllocationDevice = hDevice;
464+
return getActiveDeviceAlloc(offset);
375465
}
376466

377467
// TODO: see if it's better to migrate the memory to the specified device

unified-runtime/source/adapters/level_zero/v2/memory.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ struct ur_discrete_buffer_handle_t : ur_mem_buffer_t {
173173

174174
void *getActiveDeviceAlloc(size_t offset = 0);
175175
void *allocateOnDevice(ur_device_handle_t hDevice, size_t size);
176+
// Ensures a device allocation exists for hDevice and returns its pointer.
177+
// Unlike allocateOnDevice, does NOT update activeAllocationDevice, so it
178+
// is safe to call before the data migration is complete.
179+
void *ensureDeviceAlloc(ur_device_handle_t hDevice, size_t size);
176180
ur_result_t migrateBufferTo(ur_device_handle_t hDevice, void *src,
177181
size_t size);
178182
};

unified-runtime/test/adapters/level_zero/v2/memory_residency.cpp

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -562,12 +562,18 @@ TEST_P(urMemoryMultiResidencyTest, p2pReadFailsAfterRevokingAccess) {
562562
}
563563

564564
// Verify that a USM allocation on devices[0] is NOT made resident on
565-
// devices[1] when P2P access has not been enabled. The feature under test
566-
// restricts residency, not hardware access: Level Zero hardware can still
567-
// transfer data cross-device via the interconnect regardless of residency
568-
// state, so the copy result is not checked here. The observable guarantee
569-
// is that devices[1] free memory must not decrease by a full allocSize,
570-
// proving the allocation was never pinned on the peer device.
565+
// devices[1] when P2P access has not been enabled. This test runs after
566+
// several P2P enable/disable cycles to confirm that the residency restriction
567+
// is still enforced once P2P is turned back off.
568+
//
569+
// The memory check is done immediately after urUSMDeviceAlloc, without
570+
// creating a queue or issuing any GPU work. Waiting for GPU operations to
571+
// complete (e.g. urQueueFinish) introduces a timing window during which
572+
// background activity — async driver cleanup from earlier tests, other
573+
// concurrent GPU workloads on shared CI hardware — can change the free-memory
574+
// reading on devices[1] and cause spurious failures. The allocation step
575+
// alone is sufficient to trigger any peer-residency side-effects, so
576+
// measuring immediately after it keeps the window as short as possible.
571577
TEST_P(urMemoryMultiResidencyTest, allocationNotResidentOnPeerWithoutP2P) {
572578
constexpr size_t allocSize = kAllocSize;
573579
static constexpr uint8_t fillPattern = 0xAB;
@@ -596,8 +602,8 @@ TEST_P(urMemoryMultiResidencyTest, allocationNotResidentOnPeerWithoutP2P) {
596602
// Allocate on devices[0] WITHOUT enabling P2P — must not consume
597603
// devices[1] memory.
598604
void *srcPtr = nullptr;
599-
ASSERT_NO_FATAL_FAILURE(
600-
allocAndFillOnDevice0(allocSize, fillPattern, &srcPtr));
605+
ASSERT_SUCCESS(urUSMDeviceAlloc(context, devices[0], nullptr, nullptr,
606+
allocSize, &srcPtr));
601607

602608
uint64_t currentMemFreePeer = 0;
603609
ur_result_t memRes =

unified-runtime/test/conformance/enqueue/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ add_conformance_kernels_test(enqueue
1414
urEnqueueMemBufferCopy.cpp
1515
urEnqueueMemBufferFill.cpp
1616
urEnqueueMemBufferMap.cpp
17+
urEnqueueMemBufferMultiDeviceMigration.cpp
1718
urEnqueueMemBufferRead.cpp
1819
urEnqueueMemBufferReadRect.cpp
1920
urEnqueueMemBufferWrite.cpp
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Part of the LLVM Project, under the Apache License v2.0 with LLVM
2+
// Exceptions. See https://llvm.org/LICENSE.txt for license information.
3+
//
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Multi-device buffer tests that stress host-staged migration when a discrete
7+
// buffer is accessed from different devices/queues (for example when device
8+
// peer access is not available). Corresponds to L0 v2 discrete-buffer
9+
// getDevicePtr migration ordering.
10+
//
11+
// The tests cover two migration paths inside getDevicePtr:
12+
// - Async path (cmdList != nullptr): triggered by urEnqueueMem* operations.
13+
// - Sync fallback (cmdList == nullptr): triggered by urMemGetNativeHandle.
14+
15+
#include <uur/fixtures.h>
16+
#include <vector>
17+
18+
struct urEnqueueMemBufferMultiDeviceMigrationTest
19+
: uur::urMultiDeviceMemBufferQueueTest {
20+
void SetUp() override {
21+
UUR_RETURN_ON_FATAL_FAILURE(uur::urMultiDeviceMemBufferQueueTest::SetUp());
22+
23+
if (devices.size() < 2) {
24+
GTEST_SKIP() << "Test requires at least 2 devices";
25+
}
26+
27+
// Check that the USM P2P extension is supported on both devices.
28+
for (size_t i = 0; i < 2; i++) {
29+
ur_bool_t usm_p2p_support = false;
30+
ASSERT_SUCCESS(
31+
urDeviceGetInfo(devices[i], UR_DEVICE_INFO_USM_P2P_SUPPORT_EXP,
32+
sizeof(usm_p2p_support), &usm_p2p_support, nullptr));
33+
if (!usm_p2p_support) {
34+
GTEST_SKIP() << "EXP usm p2p feature is not supported on device " << i;
35+
}
36+
}
37+
38+
// This test exercises the host-mediated migration fallback, which is only
39+
// triggered when P2P access is NOT available between the two devices.
40+
// Skip if hardware P2P is present — the fallback path would never run.
41+
int p2pSupported = 0;
42+
ur_result_t res = urUsmP2PPeerAccessGetInfoExp(
43+
devices[0], devices[1], UR_EXP_PEER_INFO_UR_PEER_ACCESS_SUPPORT,
44+
sizeof(p2pSupported), &p2pSupported, nullptr);
45+
if (res == UR_RESULT_SUCCESS && p2pSupported) {
46+
GTEST_SKIP() << "Devices have P2P access; host-migration path is not "
47+
"exercised";
48+
}
49+
}
50+
};
51+
UUR_INSTANTIATE_PLATFORM_TEST_SUITE(urEnqueueMemBufferMultiDeviceMigrationTest);
52+
53+
TEST_P(urEnqueueMemBufferMultiDeviceMigrationTest,
54+
AsyncFillThenReadOnSecondQueueWithWait) {
55+
const uint32_t pattern = 0xA5A5A501;
56+
ur_event_handle_t fillEv = nullptr;
57+
ASSERT_SUCCESS(urEnqueueMemBufferFill(queues[0], buffer, &pattern,
58+
sizeof(pattern), 0, size, 0, nullptr,
59+
&fillEv));
60+
ASSERT_NE(fillEv, nullptr);
61+
62+
std::vector<uint32_t> output(count, 0);
63+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[1], buffer, true, 0, size,
64+
output.data(), 1, &fillEv, nullptr));
65+
66+
ASSERT_SUCCESS(urEventRelease(fillEv));
67+
68+
for (size_t i = 0; i < count; ++i) {
69+
ASSERT_EQ(pattern, output[i]) << "Mismatch at index " << i;
70+
}
71+
}
72+
73+
TEST_P(urEnqueueMemBufferMultiDeviceMigrationTest,
74+
PingPongFillBetweenTwoDeviceQueues) {
75+
const uint32_t pattern1 = 0xC001D00u;
76+
ur_event_handle_t evFill1 = nullptr;
77+
ASSERT_SUCCESS(urEnqueueMemBufferFill(queues[0], buffer, &pattern1,
78+
sizeof(pattern1), 0, size, 0, nullptr,
79+
&evFill1));
80+
ASSERT_NE(evFill1, nullptr);
81+
82+
std::vector<uint32_t> stage1(count, 0);
83+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[1], buffer, true, 0, size,
84+
stage1.data(), 1, &evFill1, nullptr));
85+
ASSERT_SUCCESS(urEventRelease(evFill1));
86+
for (size_t i = 0; i < count; ++i) {
87+
ASSERT_EQ(pattern1, stage1[i]);
88+
}
89+
90+
const uint32_t pattern2 = 0xD00DAD00u;
91+
ur_event_handle_t evFill2 = nullptr;
92+
ASSERT_SUCCESS(urEnqueueMemBufferFill(queues[1], buffer, &pattern2,
93+
sizeof(pattern2), 0, size, 0, nullptr,
94+
&evFill2));
95+
ASSERT_NE(evFill2, nullptr);
96+
97+
std::vector<uint32_t> stage2(count, 0);
98+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[0], buffer, true, 0, size,
99+
stage2.data(), 1, &evFill2, nullptr));
100+
ASSERT_SUCCESS(urEventRelease(evFill2));
101+
for (size_t i = 0; i < count; ++i) {
102+
ASSERT_EQ(pattern2, stage2[i]);
103+
}
104+
}
105+
106+
TEST_P(urEnqueueMemBufferMultiDeviceMigrationTest,
107+
ChainedAsyncOpsAcrossQueuesWithEvents) {
108+
const uint32_t patternA = 0x11111111u;
109+
ur_event_handle_t evFill = nullptr;
110+
ASSERT_SUCCESS(urEnqueueMemBufferFill(queues[0], buffer, &patternA,
111+
sizeof(patternA), 0, size, 0, nullptr,
112+
&evFill));
113+
ASSERT_NE(evFill, nullptr);
114+
115+
std::vector<uint32_t> verifyA(count, 0);
116+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[1], buffer, true, 0, size,
117+
verifyA.data(), 1, &evFill, nullptr));
118+
ASSERT_SUCCESS(urEventRelease(evFill));
119+
for (size_t i = 0; i < count; ++i) {
120+
ASSERT_EQ(patternA, verifyA[i]);
121+
}
122+
123+
const uint32_t patternB = 0x22222222u;
124+
std::vector<uint32_t> hostB(count, patternB);
125+
ur_event_handle_t evWrite = nullptr;
126+
ASSERT_SUCCESS(urEnqueueMemBufferWrite(queues[1], buffer, true, 0, size,
127+
hostB.data(), 0, nullptr, &evWrite));
128+
ASSERT_NE(evWrite, nullptr);
129+
130+
std::vector<uint32_t> verifyB(count, 0);
131+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[0], buffer, true, 0, size,
132+
verifyB.data(), 1, &evWrite, nullptr));
133+
ASSERT_SUCCESS(urEventRelease(evWrite));
134+
for (size_t i = 0; i < count; ++i) {
135+
ASSERT_EQ(patternB, verifyB[i]);
136+
}
137+
}
138+
139+
// Exercise the synchronous fallback migration path in getDevicePtr
140+
// (cmdList == nullptr), which is triggered by urMemGetNativeHandle.
141+
// Fill the buffer on device 0, then request its native pointer on device 1 to
142+
// force a synchronous host-staged migration, then verify the data on device 1.
143+
TEST_P(urEnqueueMemBufferMultiDeviceMigrationTest,
144+
SyncFallbackMigrationViaNativeHandle) {
145+
const uint32_t pattern = 0xDEADBEEFu;
146+
ASSERT_SUCCESS(urEnqueueMemBufferFill(queues[0], buffer, &pattern,
147+
sizeof(pattern), 0, size, 0, nullptr,
148+
nullptr));
149+
ASSERT_SUCCESS(urQueueFinish(queues[0]));
150+
151+
// urMemGetNativeHandle calls getDevicePtr with cmdList == nullptr,
152+
// triggering the synchronous device->host->device migration path.
153+
ur_native_handle_t nativePtr = 0;
154+
ASSERT_SUCCESS(urMemGetNativeHandle(buffer, devices[1], &nativePtr));
155+
ASSERT_NE(nativePtr, (ur_native_handle_t)0);
156+
157+
std::vector<uint32_t> output(count, 0);
158+
ASSERT_SUCCESS(urEnqueueMemBufferRead(queues[1], buffer, true, 0, size,
159+
output.data(), 0, nullptr, nullptr));
160+
for (size_t i = 0; i < count; ++i) {
161+
ASSERT_EQ(pattern, output[i]) << "Mismatch at index " << i;
162+
}
163+
}

0 commit comments

Comments
 (0)