A*寻路算法是游戏开发和路径规划领域中最经典的算法之一,其核心思想是通过开放列表维护待探索节点,不断选取代价最小的节点进行扩展,直到找到目标节点。在传统的C++串行实现中,开放列表的节点选取、扩展操作都是顺序执行的,当地图规模增大、节点数量增多时,性能会出现明显的下降。CUDA作为NVIDIA推出的并行计算平台,能够同时调度成千上万个线程并行处理任务,非常适合优化这类存在大量可并行计算逻辑的算法。将A*寻路算法移植到CUDA上,可以充分利用GPU的并行计算能力,显著提升大规模地图场景下的寻路效率。

串行A*算法的核心逻辑
在讨论如何将A*算法移植到CUDA之前,首先需要深入理解标准C++串行A*算法的核心实现逻辑。串行版本的A*算法主要依赖优先队列来维护开放列表,通过启发函数估算节点到目标的距离,从而指导搜索方向。算法的执行过程可以概括为:从起点出发,每次从开放列表中取出f值最小的节点,将其相邻的可通行节点加入开放列表,直到取出目标节点为止。
在数据结构方面,串行实现通常使用std::priority_queue作为开放列表,使用std::unordered_map来快速查找节点是否已经存在于开放列表或关闭列表中。每个节点需要记录坐标、从起点到当前节点的实际代价g、当前节点到目标节点的预估代价h、总代价f以及父节点指针。下面是一个简化版的串行A*实现,保留了算法的核心逻辑:
#include <vector>
#include <queue>
#include <unordered_map>
#include <cmath>
#include <algorithm>
// 节点结构体,记录坐标、代价、父节点信息
struct Node {
int x;
int y;
float g; // 起点到当前节点的实际代价
float h; // 当前节点到目标节点的预估代价
float f; // 总代价 g + h
Node* parent;
Node(int _x, int _y) : x(_x), y(_y), g(0), h(0), f(0), parent(nullptr) {}
// 重载小于运算符,用于优先队列排序
bool operator<(const Node& other) const {
return f > other.f; // 小顶堆,f值小的优先
}
};
// 计算曼哈顿距离作为启发函数
float heuristic(Node* a, Node* b) {
return std::abs(a->x - b->x) + std::abs(a->y - b->y);
}
// 串行A*寻路实现
std::vector<Node*> astar_serial(Node* start, Node* end, const std::vector<std::vector<int>>& map) {
std::priority_queue<Node> open_list;
std::unordered_map<int, Node*> open_map; // 快速查找开放列表中的节点
std::unordered_map<int, Node*> closed_map; // 已探索节点
start->h = heuristic(start, end);
start->f = start->g + start->h;
open_list.push(*start);
open_map[start->x * map[0].size() + start->y] = start;
// 四个方向的移动偏移
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};
while (!open_list.empty()) {
Node current = open_list.top();
open_list.pop();
Node* current_ptr = open_map[current.x * map[0].size() + current.y];
// 到达目标节点,回溯路径
if (current.x == end->x && current.y == end->y) {
std::vector<Node*> path;
Node* node = current_ptr;
while (node != nullptr) {
path.push_back(node);
node = node->parent;
}
std::reverse(path.begin(), path.end());
return path;
}
// 将当前节点移入关闭列表
closed_map[current.x * map[0].size() + current.y] = current_ptr;
open_map.erase(current.x * map[0].size() + current.y);
// 扩展相邻节点
for (int i = 0; i < 4; i++) {
int nx = current.x + dx[i];
int ny = current.y + dy[i];
// 检查边界和障碍物
if (nx < 0 || nx >= map.size() || ny < 0 || ny >= map[0].size() || map[nx][ny] == 1) {
continue;
}
int key = nx * map[0].size() + ny;
// 如果节点已在关闭列表,跳过
if (closed_map.find(key) != closed_map.end()) {
continue;
}
float new_g = current.g + 1.0f;
// 如果节点不在开放列表,或者新的g值更小,更新节点
if (open_map.find(key) == open_map.end() || new_g < open_map[key]->g) {
Node* neighbor = open_map.find(key) == open_map.end() ? new Node(nx, ny) : open_map[key];
neighbor->g = new_g;
neighbor->h = heuristic(neighbor, end);
neighbor->f = neighbor->g + neighbor->h;
neighbor->parent = current_ptr;
if (open_map.find(key) == open_map.end()) {
open_list.push(*neighbor);
open_map[key] = neighbor;
}
}
}
}
return {}; // 未找到路径
}
上述代码中,启发函数采用曼哈顿距离,适用于四方向移动的网格地图。优先队列通过重载operator<实现小顶堆,保证每次取出的都是f值最小的节点。需要注意的是,当发现某节点已有更优路径时,需要更新其g值和父节点,但由于优先队列不支持随机访问更新,实际工程中可能需要将旧节点标记为失效后重新入队。
A*算法的并行性分析
要将A*算法移植到CUDA平台,首先需要深入分析算法中哪些部分可以并行执行。串行A*的性能瓶颈主要体现在两个关键环节:一是每次从开放列表中选取最小f值节点的操作,这个操作需要对整个优先队列进行排序和维护;二是扩展当前节点时对每个相邻节点的检查和代价计算逻辑。
在这两个环节中,相邻节点的检查是完全独立的,各个方向之间没有数据依赖关系,非常适合用GPU多线程并行处理。具体来说,当前节点的四个相邻方向可以分别由四个线程同时进行边界检查、障碍物检查和代价计算,从而将串行的循环展开为并行的计算。而开放列表的维护由于需要全局排序和去重,并行实现的复杂度较高,直接在GPU上实现高效的并发优先队列是一个具有挑战性的问题。
基于以上分析,在实际移植中可以采用折中方案:由CPU维护开放列表的全局排序和去重逻辑,而将节点扩展的计算任务交给GPU并行处理。这种方案虽然无法完全消除CPU和GPU之间的数据传输开销,但能够有效利用GPU的并行计算能力加速最耗时的节点扩展环节,是很多轻量级CUDA移植方案的常用思路。后续的代码实现也将基于这一思路展开。
CUDA移植的核心步骤
数据结构的适配
CUDA中无法直接高效使用C++的STL容器,如std::vector、std::priority_queue等,因此需要将节点数据、地图数据转换为C风格数组或者CUDA原生支持的数据结构。在设备端,指针的使用需要格外谨慎,因为GPU显存的地址空间与主机内存是分离的,直接传递主机端指针会导致访问越界错误。因此,设备端结构体中应使用索引而非指针来引用其他节点。
下面定义设备端可用的节点结构体和地图数据结构。与主机端结构体保持字段一致,但去掉了运算符重载等设备端不需要的功能,并将父节点指针替换为索引:
// 设备端节点结构体
struct DeviceNode {
int x;
int y;
float g;
float h;
float f;
int parent_idx; // 用索引代替指针,避免设备端指针问题
bool valid; // 标记节点是否有效
};
// 设备端地图和辅助数据
struct DeviceMapData {
int* map; // 地图数据,0可走1障碍
int width;
int height;
int start_x;
int start_y;
int end_x;
int end_y;
};
节点扩展节点扩展
节点扩展是A*算法中计算密度最高的部分。在串行实现中,每次从优先队列中弹出最优节点后,需要检查其周围8个邻居节点,判断是否可达、是否已经访问过,然后计算g值、h值和f值,并将新节点插入优先队列。当路径搜索空间较大时,这一过程会被执行数十万甚至数百万次。
将这一过程并行化的基本思路是:不再逐个处理邻居节点,而是为每个待扩展节点的邻居分配独立的线程。考虑到A*算法通常同时需要扩展多个节点而非仅一个,可以进一步将粒度放宽,让每个线程块负责一个节点的扩展,线程块内的每个线程处理一个邻居方向。
下面的CUDA内核实现了单节点的邻居扩展逻辑。每个线程处理一个方向,根据方向索引计算邻居坐标,完成合法性检查、代价计算和结果写回:
__global__ void expandNodeKernel(const DeviceMapData mapData,
const DeviceNode* inputNodes,
DeviceNode* outputNodes,
int* outputCount,
int maxOutput) {
int nodeIdx = blockIdx.x;
int dirIdx = threadIdx.x;
if (nodeIdx >= 1 || dirIdx >= 8) return;
const DeviceNode current = inputNodes[nodeIdx];
if (!current.valid) return;
// 8个方向的偏移量,前4个为上下左右,后4个为对角线
__shared__ int dx[8];
__shared__ int dy[8];
if (threadIdx.x == 0) {
dx[0] = -1; dy[0] = 0; // 左
dx[1] = 1; dy[1] = 0; // 右
dx[2] = 0; dy[2] = -1; // 上
dx[3] = 0; dy[3] = 1; // 下
dx[4] = -1; dy[4] = -1; // 左上
dx[5] = 1; dy[5] = -1; // 右上
dx[6] = -1; dy[6] = 1; // 左下
dx[7] = 1; dy[7] = 1; // 右下
}
__syncthreads();
int nx = current.x + dx[dirIdx];
int ny = current.y + dy[dirIdx];
// 边界检查
if (nx < 0 || nx >= mapData.width || ny < 0 || ny >= mapData.height) {
return;
}
// 障碍物检查
int mapIdx = ny * mapData.width + nx;
if (mapData.map[mapIdx] == 1) {
return;
}
// 对角线移动时检查是否穿越障碍物角点
if (dirIdx >= 4) {
int corner1_x = current.x + dx[dirIdx];
int corner1_y = current.y;
int corner2_x = current.x;
int corner2_y = current.y + dy[dirIdx];
int c1Idx = corner1_y * mapData.width + corner1_x;
int c2Idx = corner2_y * mapData.width + corner2_x;
if (mapData.map[c1Idx] == 1 || mapData.map[c2Idx] == 1) {
return;
}
}
// 计算代价
float stepCost = (dirIdx < 4) ? 1.0f : 1.41421f;
float newG = current.g + stepCost;
int dx_end = nx - mapData.end_x;
int dy_end = ny - mapData.end_y;
float newH = sqrtf((float)(dx_end * dx_end + dy_end * dy_end));
float newF = newG + newH;
// 使用原子操作获取输出位置
int outIdx = atomicAdd(outputCount, 1);
if (outIdx < maxOutput) {
DeviceNode outNode;
outNode.x = nx;
outNode.y = ny;
outNode.g = newG;
outNode.h = newH;
outNode.f = newF;
outNode.parent_idx = nodeIdx;
outNode.valid = true;
outputNodes[outIdx] = outNode;
}
}
这个内核的设计中,方向偏移量存储在共享内存中,所有线程在同步后统一读取,避免了重复计算。原子操作用于在多个线程同时写回结果时保证输出索引的唯一性。然而,这个内核也存在明显的局限性:它仅扩展一个节点,每个线程块只有一个启用的线程块,GPU的利用率极低。
为了提升并行度,需要让内核能够同时扩展多个节点。一个自然的改进是将输入节点数组扩展为一批节点,每个线程块处理一个节点,线程块内的8个线程分别处理8个方向。这样,当一批中有多个待扩展节点时,可以同时调度多个线程块并行工作。CUDA的线程块调度器会自动在不同SM之间分配这些线程块,实现真正的并行扩展。
改进后的批量扩展思路如下:CPU端每次从优先队列中批量取出若干最优节点,将这些节点打包为一个数组传输到设备端;设备端为每个节点启动一个线程块,每个线程块内的8个线程并行计算该节点的邻居;所有邻居结果统一写入一个输出缓冲区,由CPU端统一读取后进行去重和入队操作。
批量扩展的版本需要关注的另一个问题是输出缓冲区的大小管理。假如一次批量扩展N个节点,每个节点最多产生8个邻居,那么最坏情况下需要8N个输出槽位。可以在设备端分配一个足够大的输出缓冲区,并在每次调用内核前将其清零,然后依靠原子操作填充实际输出。CPU端在调用后读取输出计数,仅处理有效的输出项。
内核修改后,核心逻辑与单节点版本相同,但节点索引和线程块索引的关系发生了变化。每个线程块处理一个输入节点,块内的线程仍然各自处理一个方向:
__global__ void expandBatchKernel(const DeviceMapData mapData,
const DeviceNode* inputNodes,
int inputCount,
DeviceNode* outputNodes,
int* outputCount,
int maxOutput) {
int batchIdx = blockIdx.x;
int dirIdx = threadIdx.x;
if (batchIdx >= inputCount || dirIdx >= 8) return;
const DeviceNode current = inputNodes[batchIdx];
if (!current.valid) return;
// 方向偏移量,与前一个内核相同
__shared__ int dx[8];
__shared__ int dy[8];
if (threadIdx.x == 0) {
dx[0] = -1; dy[0] = 0;
dx[1] = 1; dy[1] = 0;
dx[2] = 0; dy[2] = -1;
dx[3] = 0; dy[3] = 1;
dx[4] = -1; dy[4] = -1;
dx[5] = 1; dy[5] = -1;
dx[6] = -1; dy[6] = 1;
dx[7] = 1; dy[7] = 1;
}
__syncthreads();
int nx = current.x + dx[dirIdx];
int ny = current.y + dy[dirIdx];
if (nx < 0 || nx >= mapData.width || ny < 0 || ny >= mapData.height) {
return;
}
int mapIdx = ny * mapData.width + nx;
if (mapData.map[mapIdx] == 1) {
return;
}
if (dirIdx >= 4) {
int corner1_x = current.x + dx[dirIdx];
int corner1_y = current.y;
int corner2_x = current.x;
int corner2_y = current.y + dy[dirIdx];
int c1Idx = corner1_y * mapData.width + corner1_x;
int c2Idx = corner2_y * mapData.width + corner2_x;
if (mapData.map[c1Idx] == 1 || mapData.map[c2Idx] == 1) {
return;
}
}
float stepCost = (dirIdx < 4) ? 1.0f : 1.41421f;
float newG = current.g + stepCost;
int dx_end = nx - mapData.end_x;
int dy_end = ny - mapData.end_y;
float newH = sqrtf((float)(dx_end * dx_end + dy_end * dy_end));
float newF = newG + newH;
int outIdx = atomicAdd(outputCount, 1);
if (outIdx < maxOutput) {
DeviceNode outNode;
outNode.x = nx;
outNode.y = ny;
outNode.g = newG;
outNode.h = newH;
outNode.f = newF;
outNode.parent_idx = batchIdx;
outNode.valid = true;
outputNodes[outIdx] = outNode;
}
}
批量扩展中,parent_idx字段记录的是输入批次中的索引。CPU端在回读结果后,需要根据这个索引映射回真实的节点ID。这意味着CPU端在构造输入批次时,必须同时维护一个从批次索引到节点ID的映射表。这个映射表可以使用简单的数组实现,在批量取出节点时按顺序记录即可。
在批量方案下,CPU与GPU之间的数据传输模式变得更加规律。每次迭代中,CPU将一批节点上传到设备端,调用内核进行扩展,然后下载扩展结果。数据传输的单位从单个节点变为节点批次,减少了传输次数,也提高了每次传输的有效载荷比例。对于中等规模的地图,这种批量模式已经能够带来一定的加速。
内存管理与数据传输
在CUDA程序中,显存的分配和释放是有代价的。频繁调用cudaMalloc和cudaFree会引入显著的性能开销,并且可能导致显存碎片化。在实际实现中,应当预先分配所有需要的缓冲区,在搜索过程中重复使用,直到搜索结束统一释放。
需要预先分配的缓冲区包括:设备端地图数据、输入节点数组、输出节点数组、输出计数器。其中地图数据在整个搜索过程中保持不变,可以在搜索开始前一次性上传。输入和输出节点数组的大小取决于批量扩展的规模,可以根据地图尺寸估算一个上限。
地图数据的传输相对简单。假设主机端地图存储在一个int数组中,宽度和高宽已知,那么上传到设备端的操作如下:
int* d_map;
size_t mapSize = mapWidth * mapHeight * sizeof(int);
cudaMalloc(&d_map, mapSize);
cudaMemcpy(d_map, h_map, mapSize, cudaMemcpyHostToDevice);
设备端地图数据通常在整个搜索过程中保持只读状态,因此可以将其绑定到CUDA的纹理内存或只读缓存中。对于简单的场景,直接使用全局内存也能够接受,但在访问模式频繁重复的情况下,使用__ldg函数或者通过const __restrict__限定符可以帮助编译器生成更高效的只读缓存访问指令。
输入和输出节点缓冲区的管理则更为复杂。输入缓冲区需要在每次迭代前由CPU填充最新的待扩展节点,输出缓冲区则在内核执行后由CPU读取。为了避免分配和释放的开销,可以在搜索开始前根据批量大小分配足够大的缓冲区,并在每次迭代中复用。
对于输出缓冲区,需要特别注意的是输出计数器的重置。在每次内核调用前,必须将设备端的输出计数器清零,否则原子操作会在上次的计数值基础上继续累加,导致输出索引错误。清零操作可以通过cudaMemset完成,只需要重置一个int的大小:
cudaMemset(d_outputCount, 0, sizeof(int));
这个操作的开销非常小,但必须在每次内核启动前执行。一个常见的错误是忘记重置计数器,导致第二次迭代的输出索引从错误的位置开始,最终破坏输出缓冲区的内容。
节点去重与开放列表管理
GPU端的节点扩展产生的新节点中,大量节点可能是重复的。例如,节点A和节点B同时扩展时,可能都会生成节点C的子节点。如果不进行去重,开放列表中会出现大量重复节点,既浪费内存,也增加了后续处理的负担。
在CPU端进行去重是相对容易的,因为可以使用哈希集合或平衡树等数据结构。CPU收到GPU返回的新节点后,依次检查每个节点是否已经在开放列表或关闭列表中。如果已经存在,则比较g值,决定是否需要更新;如果不存在,则直接插入开放列表。
去重操作的具体实现取决于开放列表的数据结构。如果使用std::priority_queue,则无法直接查询某个节点是否已经存在。一种常用的做法是同时维护一个std::unordered_set或者std::map来记录已入队节点的坐标和当前最优g值。入队前先查询该集合,根据查询结果决定是否入队。
考虑到坐标范围有限,也可以使用一个二维数组来存储每个节点的状态。数组的每个元素可以编码为三种状态:未访问、在开放列表中、在关闭列表中。对于在开放列表中的节点,还可以额外存储其当前的g值和父节点索引。这种方案避免了哈希计算的开销,但需要额外的内存空间来存储状态数组。对于较小规模的地图,这是一个简单高效的方案。
当使用一维状态数组时,索引的计算方式为:
int stateIdx = y * mapWidth + x;
状态数组的类型可以定义为unsigned char,每个元素仅占用一个字节,对于百万级节点的地图,状态数组仅需要1MB的内存。如果需要同时存储g值,可以使用float数组,每个元素占用4字节,总内存需求也会相应增加。
主机端去重逻辑的伪代码大致如下:
for each returned node n:
idx = n.y * width + n.x
if state[idx] == CLOSED:
continue
if state[idx] == UNVISITED:
state[idx] = OPEN
gScore[idx] = n.g
parent[idx] = n.parent_idx
push n to open list
else if state[idx] == OPEN:
if n.g < gScore[idx]:
gScore[idx] = n.g
parent[idx] = n.parent_idx
push n to open list
这里采用允许重复入队的策略:如果发现更优的g值,则将新节点再次插入优先队列,旧节点在弹出时通过状态检查来惰性丢弃。这种策略避免了在优先队列中执行删除操作的高昂代价,是A*算法实现中的常见做法。
路径回溯
当目标节点被扩展后,搜索过程结束。此时需要从目标节点出发,沿着父节点指针回溯到起始节点,得到完整路径。在GPU加速的实现中,由于节点数据可能分散在设备端和主机端,路径回溯通常由CPU端完成,因为回溯过程本身是串行的,不适合GPU并行处理。
CPU端需要维护足够的信息来支持回溯。在每次将节点加入开放列表时,需要记录该节点的父节点坐标。如果使用状态数组方案,可以同时维护一个父节点坐标数组,每个节点的父节点索引按坐标存储。当目标节点被找到时,从目标坐标开始,依次查询父节点坐标数组,直到回溯到起点。
回溯得到的路径通常需要反转,因为从目标节点回溯得到的是从目标到起点的顺序。反转后即可得到从起点到目标的路径。
性能评估与优化方向
基于上述方案的CUDA移植,在中小规模地图上的性能表现通常能够与经过优化的CPU实现持平,甚至获得一定的加速。加速效果取决于多个因素:地图的规模和结构、批量扩展的批次大小、GPU的规格以及CPU与GPU之间的传输带宽。
当批量大小过小时,GPU的并行度不足,且每次迭代的传输开销占比过高,性能可能不如纯CPU实现。当批量大小过大时,虽然GPU利用率提高,但可能扩展了大量非最优节点,导致整体工作量增加。实际的最优批量大小通常需要通过实验确定,不同地图上的最优值可能不同。
一个值得注意的优化方向是调整节点扩展的粒度。目前的方案中,每个线程块处理一个节点,每个线程处理一个方向。实际上,如果一个节点扩展涉及的邻居全部不可达,那么该线程块的8个线程几乎全部提前返回,浪费了计算资源。可以考虑让每个线程独立处理一个完整的节点扩展任务,即每个线程依次检查8个方向。这种方式虽然单个线程的工作量增加,但在某些情况下由于减少了线程块调度的开销,反而可能更高效。
另一个优化方向是减少原子操作的竞争。当前内核中,所有线程块共享同一个输出计数器,所有输出写回都需要通过同一个原子变量。当批量较大时,这个原子操作可能成为瓶颈。可以考虑为每个线程块分配独立的输出区域,每个线程块内的原子操作仅竞争块内的计数器,从而降低竞争程度。这种方式需要在CPU端做额外的整理工作,将各个块的输出拼接起来。
对于更大规模的地图,还可以考虑将关闭列表的维护也部分转移到GPU端。但如前文所述,由于A*算法固有的顺序依赖性和不规则内存访问模式,完全GPU化的开放列表和关闭列表维护仍然是一个开放的研究问题。
经验总结
将A*路径搜索算法移植到CUDA的过程,本质上是对算法中可并行部分和不可并行部分的识别与划分过程。节点扩展环节天然适合GPU并行处理,而开放列表的排序和去重则更适合CPU的串行逻辑。这种混合方案虽然引入了数据传输开销,但在许多实际场景中仍然能够带来可观的性能提升。
在实现过程中,数据结构的设计需要同时考虑主机端和设备端的可访问性。使用索引代替指针、预分配显存缓冲区、批量传输数据等策略,都是减少不必要开销的有效手段。对于追求更高性能的场景,可以考虑使用CUDA流来重叠数据传输和计算,将当前批次的结果下载与下一批次的节点扩展同时进行。
尽管完全GPU化的A*算法仍然面临诸多挑战,但部分并行化的方案已经足够满足许多实时导航和游戏AI的需求。随着GPU架构的演进和CUDA编程模型的不断完善,未来可能会有更多高效的启发式搜索并行化方案出现。
CUDA A*算法 GPU加速 C++并行计算修改时间:2026-07-20 03:04:06
免责声明: 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。