Skip to content

Commit adabd63

Browse files
shoumikhinr
andauthored
Copy a tensor back from the device before ETDump writes it (pytorch#22095)
### Summary ETDump records an intermediate tensor by handing the tensor's data pointer to a data sink, and every sink reads those bytes with a plain host read: ```cpp memcpy(cur_data_begin, ptr, length); ``` When the tensor lives on an accelerator that pointer is not host memory, so the read segfaults. A program placed on CUDA crashes as soon as tracing is turned on, which is exactly when someone is trying to debug it. Returning an error instead would not help. All four callers wrap the result in `ET_CHECK_MSG`, so an error aborts the process rather than skipping the tensor. This change brings the data back to host memory first. When the tensor is not on CPU, ETDump looks up the allocator registered for that device type, stages the bytes into a temporary host buffer with `copy_device_to_host`, writes that buffer to the sink and frees it. A tensor on CPU keeps the old path and copies nothing extra. If no allocator is registered for the device, ETDump now reports `NotFound` and logs the device type instead of reading the pointer anyway. ### Test plan Two new test files, each with a CMake target and a Buck target. `devtools/etdump/tests/etdump_device_test.cpp` registers the existing `MockCudaAllocator`, which backs its device memory with host memory, and has two tests. One logs a tensor tagged as CUDA and checks both that ETDump went through the allocator and that the bytes reached the debug buffer. The other logs a tensor on CPU and checks that the allocator was not used at all, so the CPU path is unchanged. `devtools/etdump/tests/etdump_device_no_allocator_test.cpp` covers the case where nothing is registered for the device. The registry is a process wide static with no way to remove an entry, so that case needs a binary that never registers anything, which is why it is a second file. `devtools/etdump/tests/CMakeLists.txt` was not referenced by any parent `CMakeLists.txt`, so nothing in that directory was built by CMake. This adds `add_subdirectory(tests)` to `devtools/etdump/CMakeLists.txt` under `BUILD_TESTING`, so both new tests are picked up by `ctest`, which is how the C++ tests run. The pre-existing `sdk_etdump_tests` target stays out of the CMake build. It compiles `etdump_test.cpp`, which includes `etdump_filter.h`, which needs re2, and the devtools build does not pull re2 in. It is now guarded on re2 being available rather than being silently unreachable. With this change both new binaries pass under `ctest`: ``` 1/2 Test #1: etdump_device_test ................ Passed 2/2 Test #2: etdump_device_no_allocator_test ... Passed ``` With `etdump_flatcc.cpp` reverted to the old code and everything rebuilt, both fail: ``` Expected equality of these values: g_mock_cuda.d2h_count_ Which is: 0 1 Death test: etdump_gen.log_evalue(EValue(tensor)) Result: failed to die. ``` Also reproduced the real crash on one NVIDIA H100, with a small program that allocates through `cudaMalloc`, tags a tensor as CUDA and logs it: ``` before: Segmentation fault (core dumped) after: debug buffer holds 1.5 2.5 3.5 4.5 ``` Checked that `etdump_flatcc.cpp` still compiles with `-DUSE_ATEN_LIB`. `clang-format` reports no changes needed on the four touched C++ files. ### Landing order pytorch#22058 adds device-planned arenas to the Python bindings that ETDump can be handed pointers into, and without this change `BufferDataSink::write` would `memcpy` device memory from the host. This should land before or together with it. ### Not covered The ATen mode branch of the device type conversion only compiles. There is no ATen mode CMake build to run it in, so nothing here executes it. It is reachable in principle: `runtime/executor/tensor_parser_aten.cpp` reads the serialized device type and index, including CUDA, and builds the ATen tensor on that device. An earlier version of this description said ATen tensors carry no device metadata, which is wrong. `LogTensorOnCpuDoesNotStageThroughTheAllocator` passes with the production change reverted, since CPU tensors already went straight to the data sink. It documents the CPU path rather than locking the fix; the other two tests are the ones that require the new branch. --------- Co-authored-by: r <r@e>
1 parent 381a30c commit adabd63

7 files changed

Lines changed: 375 additions & 11 deletions

File tree

‎devtools/etdump/CMakeLists.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,7 @@ install(
9292
INCLUDES
9393
DESTINATION ${_common_include_directories}
9494
)
95+
96+
if(BUILD_TESTING)
97+
add_subdirectory(tests)
98+
endif()

‎devtools/etdump/etdump_flatcc.cpp‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
#include <executorch/devtools/etdump/etdump_schema_flatcc_builder.h>
1616
#include <executorch/devtools/etdump/etdump_schema_flatcc_reader.h>
1717
#include <executorch/devtools/etdump/utils.h>
18+
#include <executorch/runtime/core/device_allocator.h>
1819
#include <executorch/runtime/core/error.h>
1920
#include <executorch/runtime/core/exec_aten/exec_aten.h>
2021
#include <executorch/runtime/core/exec_aten/util/scalar_type_util.h>
2122
#include <executorch/runtime/platform/assert.h>
2223

24+
#ifdef USE_ATEN_LIB
25+
#include <executorch/extension/aten_util/aten_bridge.h>
26+
#endif
27+
2328
#include <flatcc/flatcc_types.h>
2429

2530
using ::executorch::aten::Tensor;
@@ -105,6 +110,75 @@ Result<etdump_Tensor_ref_t> add_tensor_entry(
105110
return etdump_Tensor_end(builder_);
106111
}
107112

113+
// The allocator registry is keyed on the runtime's own device type. In ATen
114+
// mode a tensor carries the ATen enum instead, which names many more devices
115+
// than ExecuTorch can register an allocator for.
116+
Result<::executorch::runtime::etensor::DeviceType> to_runtime_device_type(
117+
::executorch::aten::DeviceType type) {
118+
#ifdef USE_ATEN_LIB
119+
std::optional<::executorch::runtime::etensor::Device> device =
120+
::executorch::extension::torch_to_executorch_device(c10::Device(type));
121+
if (!device.has_value()) {
122+
return Error::NotSupported;
123+
}
124+
return device->type();
125+
#else
126+
return type;
127+
#endif
128+
}
129+
130+
// Stages a tensor that lives on an accelerator through host memory before
131+
// handing it to the data sink, which can only read host pointers. A data sink
132+
// stores the bytes with a plain host read, so handing it the accelerator
133+
// pointer straight would crash the process.
134+
Result<long> write_device_tensor(DataSinkBase* data_sink, Tensor tensor) {
135+
Result<::executorch::runtime::etensor::DeviceType> device_type =
136+
to_runtime_device_type(tensor.device().type());
137+
if (!device_type.ok()) {
138+
ET_LOG(
139+
Error,
140+
"ETDump cannot read a tensor on device type %d",
141+
static_cast<int>(tensor.device().type()));
142+
return device_type.error();
143+
}
144+
145+
::executorch::runtime::DeviceAllocator* allocator =
146+
::executorch::runtime::get_device_allocator(device_type.get());
147+
if (allocator == nullptr) {
148+
ET_LOG(
149+
Error,
150+
"No device allocator registered for device type %d, so a tensor on that device cannot be copied back to host memory",
151+
static_cast<int>(device_type.get()));
152+
return Error::NotFound;
153+
}
154+
155+
const size_t nbytes = tensor.nbytes();
156+
void* staging = malloc(nbytes);
157+
if (staging == nullptr) {
158+
ET_LOG(
159+
Error, "Failed to allocate %zu bytes to stage a device tensor", nbytes);
160+
return Error::MemoryAllocationFailed;
161+
}
162+
163+
Error copy_error = allocator->copy_device_to_host(
164+
staging,
165+
tensor.const_data_ptr(),
166+
nbytes,
167+
static_cast<::executorch::runtime::etensor::DeviceIndex>(
168+
tensor.device().index()));
169+
if (copy_error != Error::Ok) {
170+
free(staging);
171+
return copy_error;
172+
}
173+
174+
Result<size_t> ret = data_sink->write(staging, nbytes);
175+
free(staging);
176+
if (!ret.ok()) {
177+
return ret.error();
178+
}
179+
return static_cast<long>(ret.get());
180+
}
181+
108182
} // namespace
109183

110184
// Constructor implementation
@@ -725,6 +799,11 @@ Result<long> ETDumpGen::write_tensor_or_return_error(Tensor tensor) {
725799
if (!data_sink_) {
726800
return Error::InvalidArgument;
727801
}
802+
803+
if (!tensor.device().is_cpu()) {
804+
return write_device_tensor(data_sink_, tensor);
805+
}
806+
728807
Result<size_t> ret =
729808
data_sink_->write(tensor.const_data_ptr(), tensor.nbytes());
730809
if (!ret.ok()) {

‎devtools/etdump/targets.bzl‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,11 @@ def define_common_targets():
132132
"etdump_flatcc.h",
133133
],
134134
deps = [
135+
"//executorch/runtime/core:device_allocator",
135136
"//executorch/runtime/platform:platform",
136-
],
137+
] + ([
138+
"//executorch/extension/aten_util:aten_bridge",
139+
] if aten_mode else []),
137140
exported_deps = [
138141
":etdump_schema_flatcc",
139142
":utils",

‎devtools/etdump/tests/CMakeLists.txt‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,51 @@ include(${EXECUTORCH_ROOT}/tools/cmake/Test.cmake)
1919

2020
set(_test_srcs etdump_test.cpp)
2121

22+
# etdump_test.cpp includes etdump_filter.h, which includes <re2/re2.h>. re2 only
23+
# reaches CMake through the tokenizers submodule, which a default checkout does
24+
# not populate, so re2::re2 usually does not exist and this suite is skipped
25+
# here. Buck builds it unconditionally, which is where it runs today.
26+
#
27+
# The guard is not sufficient on its own: this target links neither re2 nor
28+
# etdump_filter.cpp (the etdump library does not compile that file), so where
29+
# re2::re2 does exist the suite fails to link. That predates this file's change,
30+
# which only narrows when it is attempted. Fixing it properly means adding
31+
# etdump_filter.cpp to the etdump library and re2 to EXTRA_LIBS.
32+
#
33+
# The two tests below avoid etdump_filter.h so they always build.
34+
if(TARGET re2::re2)
35+
et_cxx_test(
36+
sdk_etdump_tests
37+
SOURCES
38+
${_test_srcs}
39+
EXTRA_LIBS
40+
bundled_program
41+
etdump
42+
flatccrt
43+
)
44+
target_include_directories(
45+
sdk_etdump_tests PRIVATE ${CMAKE_INSTALL_PREFIX}/sdk/include
46+
${EXECUTORCH_ROOT}/third-party/flatcc/include
47+
)
48+
endif()
49+
2250
et_cxx_test(
23-
sdk_etdump_tests
24-
SOURCES
25-
${_test_srcs}
26-
EXTRA_LIBS
27-
bundled_program
28-
etdump
29-
flatccrt
51+
etdump_device_test SOURCES etdump_device_test.cpp EXTRA_LIBS etdump flatccrt
3052
)
31-
target_include_directories(
32-
sdk_etdump_tests PRIVATE ${CMAKE_INSTALL_PREFIX}/sdk/include
33-
${EXECUTORCH_ROOT}/third-party/flatcc/include
53+
54+
et_cxx_test(
55+
etdump_device_no_allocator_test SOURCES etdump_device_no_allocator_test.cpp
56+
EXTRA_LIBS etdump flatccrt
3457
)
58+
59+
# These two register a device allocator and then call into etdump, which reads
60+
# it back out of the registry. et_cxx_test links the static executorch_core, so
61+
# in a shared build, where etdump resolves the runtime from libexecutorch.so,
62+
# the archive would win the runtime symbols and the test would hold a private
63+
# registry (Utils.cmake documents that ordering). The usual remedy,
64+
# executorch_target_link_shared_runtime, cannot be called from here: the root
65+
# CMakeLists adds devtools well before it defines executorch_shared, so the
66+
# target does not exist yet at this point. These tests are run by the Buck
67+
# targets and by the default static CMake build, both of which have one
68+
# registry; a shared-build CMake run of them needs that ordering addressed
69+
# first.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
// The allocator registry is a process wide static with no way to remove an
10+
// entry, so the "nothing registered for this device" path needs a binary that
11+
// never registers anything.
12+
13+
#include <gtest/gtest.h>
14+
#include <cstdint>
15+
#include <cstdlib>
16+
17+
#include <executorch/devtools/etdump/data_sinks/buffer_data_sink.h>
18+
#include <executorch/devtools/etdump/etdump_flatcc.h>
19+
#include <executorch/runtime/core/device_allocator.h>
20+
#include <executorch/runtime/core/portable_type/tensor_impl.h>
21+
#include <executorch/runtime/platform/runtime.h>
22+
#include <executorch/test/utils/DeathTest.h>
23+
24+
using namespace ::executorch::etdump;
25+
using namespace ::executorch::runtime;
26+
using namespace ::executorch::runtime::etensor;
27+
28+
namespace {
29+
30+
TEST(ETDumpNoDeviceAllocatorTest, LogTensorOnUnregisteredDeviceAborts) {
31+
runtime_init();
32+
ASSERT_EQ(get_device_allocator(DeviceType::CUDA), nullptr);
33+
34+
// Host memory, so the test fails by not dying rather than by crashing if
35+
// ETDump ever reads the pointer instead of reporting the missing allocator.
36+
float data[] = {1.5f, 2.5f, 3.5f, 4.5f};
37+
int32_t sizes[] = {4};
38+
uint8_t dim_order[] = {0};
39+
int32_t strides[] = {1};
40+
TensorImpl impl(
41+
ScalarType::Float,
42+
1,
43+
sizes,
44+
data,
45+
dim_order,
46+
strides,
47+
TensorShapeDynamism::STATIC,
48+
DeviceType::CUDA,
49+
0);
50+
Tensor tensor(&impl);
51+
52+
const size_t debug_buf_size = 2048;
53+
void* debug_buf = malloc(debug_buf_size);
54+
auto buffer_data_sink = BufferDataSink::create(debug_buf, debug_buf_size);
55+
ASSERT_TRUE(buffer_data_sink.ok());
56+
57+
ETDumpGen etdump_gen;
58+
etdump_gen.create_event_block("test_block");
59+
etdump_gen.set_data_sink(&buffer_data_sink.get());
60+
61+
ET_EXPECT_DEATH(
62+
etdump_gen.log_evalue(EValue(tensor)), "No device allocator registered");
63+
64+
free(debug_buf);
65+
}
66+
67+
} // namespace

0 commit comments

Comments
 (0)