一个关于 AMDGPU Page Fault 的报错排查

背景 最近在做一个项目,其中涉及到构建一个函数,函数的输入是一个内存地址,函数需要解析这个内存地址中的值然后执行相应的动作。已知的是输入的内存地址是 int64 类型的数值,代表的是 amdgpu scratch memory 中的一个地址。 基于项目当前的架构,我的设计是在 opencl 中实现这个函数,然后将这个函数编译到一个 bitcode 文件中,作为一个 bitcode 文件,它可以在项目编译的时候通过 -mlink-builtin-bitcode 链接进来,调用方只需要在模块中正确地声明这个函数就可以。 这个函数是在 runtime 执行的,因为在编译期间不能获取到内存中存的数值。 问题浮现 当我完成函数代码的编写,并且成功编译到可执行文件,看起来一切顺利。但是当我运行可执行文件的时候,意外发生了,出现了 Page Fault 的错误。 原因分析 经过一盘排查之后,发现问题出在加载了非法的指针地址。输入的指针地址指向的是 scratch 地址空间(i8 addrspace(5)),为了可以访问这个地址,我通过 inttoptr 将其转换到了 generic 地址空间的指针(i8),然后再访问这个地址。我用下面的伪代码来表达一下这番操作: define protected void void @foo(i64 %0) { Entry: %1 = inttoptr i64 %0 to i8* %2 = load i8, i8* %1, align 1 ... } 而调用方首先通过 ptrtoint 将 i8 addrspace(5)* 转成了 int64,所以整个流程可以简化为下面的伪代码: define amdgpu_kernel void @bar(...) { Entry: %0 = ptrtoint i8 addrspace(5)* %ptr to i64 %1 = inttoptr i64 %0 to i8* %2 = load i8, i8* %1, align 1 ... } 看起来一切都符合逻辑,除了 scratch 的地址空间被转到了 generic 的地址空间,但是理论上 generic 指针也可以处理 scratch 的指针,只要指针指向的地址没有改变就可以了。但是指针指向的地址确实被改变了! ...

May 9, 2024

虚函数 (Virtual Function)

简介 虚函数是 C++ 中的一种成员函数,它的开头用关键字 “virtual” 进行修饰。虚函数是用于实现运行时多态的一种方式。 虚函数的简单例子 class Base { public: virtual void foo(); }; class Derived : public Base { public: void foo(); } 在以上例子中,Base 声明了一个虚函数 foo,它的派生类 Derived,重载了这个 foo 函数。在运行时,如果指针或者引用指向的将是 Base,那么调用的将是 Base::foo;如指针或者引用指向的是 Derived 对象,那么调用的将是 Derived::foo。 运行时多态 class Base { public: virtual void foo() { std::cout << "Base foo" << std::endl; }; }; class Derived : public Base { public: void foo() { std::cout << "Derived foo" << std::endl; }; }; int main() { Derived d; Base *b1 = &d; Base &b2 = d; b1->foo(); b2.foo(); return 0; } Output: Derived foo Derived foo 在上面的例子中,我们将 Base 类型的指针和引用指向 Derived 对象,在调用 foo 的时候,实际调用的是 Derived::foo,这就是运行时多态。当有函数的接口只接受基类对象的指针类型的时候,我们可以传入实际指向子类对象的指针,并且可以成功调用子类对象的成员函数。 ...

March 17, 2024

std::move

std::move 是移动语义中常见的一种标准库函数,它的作用是将一个左值转发为右值。如果读者了解完美转发(std::forward)的话,就会发现 std::move 其实是完美转发的一个子集。在一点在我之前的博客中也提到过。在 cppreference.com,对 std::move 下了如下的定义: std::move is used to indicate that an object t may be “moved from”, i.e. allowing the efficient transfer of resources from t to another object. In particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type. std::move 的声明如下: template< class T > typename std::remove_reference<T>::type&& move( T&& t ) noexcept; std::move 的声明说明了它的输出的类型是 T&&,也就是 T 的右值引用。所以当 std::move 的结果被当作右值使用的时候,会激活右值引用形参的重载函数。我们看个例子: ...

March 13, 2024

完美转发 (std::forward)

完美转发(std::forward)是一个标准库函数。它的作用是什么呢?简而言之,它可以把左值当作左值或者右值转发出去,也可以把右值当作右值转发出去。这段像绕口令一样的描述不是我的原创,而是翻译自 cppreference.com: Forwards lvalues as either lvalues or as rvalues, depending on T. Forwards rvalues as rvalues and prohibits forwarding of rvalues as lvalues. 其目的,读者诸君应该也能大概猜到,凡是涉及到左值右值的,大多是为了实现移动语义。 逐条解释 我们先看一下标准库中对 std::forward 的声明。结合一下这段代码,我们可以尝试理解一下上面的那段绕口令。 template <class T> T&& forward(typename remove_reference<T>::type& arg) noexcept; template <class T> T&& forward(typename remove_reference<T>::type&& arg) noexcept; 1. 将左值转发为左值 #include <iostream> #include <utility> using namespace std; struct Foo { Foo() { std::cout << "Foo constructor." << std::endl; } }; struct Bar { Bar(Foo&) { std::cout << "Bar copy constructor." << std::endl; } Bar(Foo&&) { std::cout << "Bar move constructor." << std::endl; } }; int main() { Foo foo; auto b1 = Bar(std::forward<Foo&>(foo)); auto b2 = Bar(foo); } Output: Foo constructor. Bar copy constructor. Bar copy constructor. 注意我们调用 std::forward 的时候,实例化的模板参数是 Foo&,因为入参 foo 是左值,所以实例化的是 std::forward 的第一种声明。用 Foo& 替换 T 之后我们可以看到,std::forward 的返回类型为 Foo& &&,而根据引用折叠规则,这一类型实际为 Foo&,因此返回类型为 Foo&,也就是 Foo 的左值引用。所以,Bar 的构造函数看到的入参是一个 Foo& 类型的右值(我们直接把 std::forward 的结果传递给了 Bar 的构造函数),因此激活的是拥有 Foo& 形参的构造函数,也就是 “copy constructor”。可以看到这一调用的结果跟我们直接传递一个左值的 Foo 对象是一样的。因此,我们称在这种情况下,将左值转发为了左值。 ...

March 13, 2024

引用折叠 (Reference Collasping)

C++ 引用折叠可以说是 C++ 一系列高级模板编程技巧(例如万能引用,完美转发)的基础。C++ 中的左值和右值都可以有引用,分别为左值引用(T&)和右值引用(T&&)。左值引用和右值引用也都是类型,所以它们也可以有引用。对于如何解释引用的引用,C++ 有一套规定:除了右值引用的右值引用折叠为右值引用之外,其他对引用的引用均折叠为左值引用。 也就是说,总共四种情况,用下面的代码总结一下: // 左值引用的左值引用折叠为左值引用 T& & → T& // 左值引用的右值引用折叠为左值引用 T& && → T& // 右值引用的左值引用折叠为左值引用 T&& & → T& // 右值引用的右值引用折叠为右值引用 T&& && → T&& 一言以蔽之,只要沾了左值引用,就永远是左值引用。通过 Compiler Explorer 验证一下以上的论点。 一些反思 如果读者了解移动语义的话,那么引用折叠会使得我们回味一下移动语义并得到一些新的见解。参考以下的代码,我们重载了函数 foo,使得它可以接受右值的入参,这是很常见的一个场景。 void foo(int &x){ std::cout << "lvalue reference version called." << std::endl; } void foo(int &&x){ std::cout << "rvalue reference version called." << std::endl; } int main() { int x = 1; foo(x); // 调用了左值引用版本的构造函数 foo(1); // 调用了右值引用版本的构造函数 return 0; } 不过,如果我传入一个左值引用类型的右值呢,哪个重载版本会被调用呢? foo(static_cast<int&>(x)); // 传入的参数是左值引用类型的右值 如果你像我之前只根据左值或者右值进行判断的话,那就会认为右值引用的版本会被调用。但是实际上被调用的是左值引用的版本,也就是 foo(int &x) 这个版本。因为我们传入的参数的类型是 int&,而且是一个右值,那么实际上对应的形参应该是 foo(int& &&),而根据引用折叠的规则,这实际上就是 foo(int&) 这个函数。那么对于如下这种入参,调用的又会是哪个版本呢? ...

March 12, 2024