基于二进制浮点数的Radix Sort实现:内存释放疑问与性能优化问询
作业背景概述
我实现了一款针对二进制浮点数的Radix Sort算法,目标是在2分钟内完成1亿个浮点数的排序。目前算法效率较高,能在约30秒内完成任务。为了对浮点数进行位运算,我参考Quake 3快速逆平方根算法相关视频,将浮点数转换为unsigned int处理。
以下是核心排序代码:
void radixsort(float *array, unsigned int length, unsigned int bits) { double sum = 0; // Create buckets float *bucketsA = (float *) malloc(length * sizeof(float)); float *bucketsB = (float *) malloc(length * sizeof(float)); unsigned int aIndex = 0, bIndex = 0; for (int i = 0; i < bits; i++) { // Sort array using digit position d as the key. for (int j = 0; j < length; j++) { if (i == 0) sum += array[j]; unsigned int conversion = * (unsigned int *) &array[j]; int positionBit = nthBit(conversion, i); if (positionBit == 0) { bucketsA[aIndex] = array[j]; aIndex++; } else { bucketsB[bIndex] = array[j]; bIndex++; } } // Combine and move sorted buckets into original array if(i == bits - 1) { reverseArray(bucketsB, bIndex); memcpy(array, bucketsB, (bIndex + 1) * sizeof(float)); memcpy(array + bIndex, bucketsA, (aIndex + 1) * sizeof(float)); } else { memcpy(array, bucketsA, (aIndex + 1) * sizeof(float)); memcpy(array + aIndex, bucketsB, (bIndex + 1) * sizeof(float)); } // Reset the memory of the buckets memset(&bucketsA[0], 0, sizeof(float) * length); memset(&bucketsB[0], 0, sizeof(float) * length); // Reset bucket index aIndex = bIndex = 0; } printf("Total: %f\n", sum); // Free reserved memory free(bucketsA); free(bucketsB); }
补充的辅助函数代码:
int nthBit(int number, int n) { return (number >> n) & 1; } void reverseArray(float *array, int size) { for (int i = 0; i < (size / 2); i++) { float swap = array[size - 1 - i]; array[size - 1 - i] = array[i]; array[i] = swap; } }
问题解答
1. 未释放堆内存为何没有触发段错误?释放内存反而拖慢速度?
首先,未释放堆内存不会直接导致段错误。段错误通常是因为访问了不属于你的内存(比如空指针、越界访问、栈溢出等),而内存泄漏(未释放堆内存)只是让进程占用的内存没有归还给操作系统,程序本身依然可以正常运行——直到系统内存耗尽才会出问题,但单个排序程序的泄漏在短时间内不会触发这个情况。
至于释放内存拖慢速度:free()本身确实有一定开销,尤其是当你释放大块内存时,操作系统需要把内存块重新加入空闲链表,做一些管理工作。如果你的程序在radixsort返回后就退出了,其实操作系统会自动回收进程的所有内存,这时候手动调用free()反而多了一层不必要的操作。不过在长期运行的程序中,内存泄漏会导致内存占用持续增长,这时候free()是必须的,但你的排序程序属于一次性任务,确实可以考虑省略(或者在性能优先的场景下暂时跳过)。
另外,你代码里用memset清空整个 buckets 也是不必要的开销——因为每次循环都会覆盖 buckets 的内容,之前的旧值根本不会被读取,完全可以去掉这两行memset,这也能节省不少时间。
2. 如何进一步优化速度?MSB优先是否更好?
你的LSB实现已经很快了,但还有不少可以优化的点:
减少内存操作开销
- 去掉不必要的
memset:如刚才所说,每次循环都会重新填充 buckets,清空操作完全多余,直接删除memset语句能节省大量时间。 - 避免
memcpy的额外开销:当前每次循环都把 buckets 复制回原数组,其实可以用双缓冲的方式,直接交替使用原数组和 buckets 作为输入输出,减少一次复制。比如第一次把原数组分到 bucketsA 和 bucketsB,第二次直接用 bucketsA/B 作为输入,写到原数组或另一个 bucket,这样能避免频繁的memcpy。 - 预计算桶的大小:当前是遍历数组时直接填充 buckets,你可以先统计当前位为0和1的元素数量,然后直接计算每个桶的起始位置,这样可以避免多次移动元素,直接一次性把元素放到正确的位置(这是基数排序的标准优化,叫"计数阶段")。比如:
这样填充的顺序更规整,缓存命中率更高。// 先统计计数 int count0 = 0, count1 = 0; for (int j = 0; j < length; j++) { unsigned int conversion = *(unsigned int *)&array[j]; if (((conversion >> i) & 1) == 0) count0++; else count1++; } // 计算位置 int pos0 = 0; int pos1 = count0; // 再填充数组 for (int j = 0; j < length; j++) { unsigned int conversion = *(unsigned int *)&array[j]; if (((conversion >> i) & 1) == 0) { buckets[pos0++] = array[j]; } else { buckets[pos1++] = array[j]; } }
利用CPU缓存优化
- 更大的基数粒度:当前每次按1位来排序(bits参数应该是32?),可以考虑按4位或8位一组来排序(即基数选16或256),这样循环次数从32次减少到8次或4次,虽然每次循环的工作量增加,但整体的循环开销和内存操作次数会减少,缓存利用率也更高。不过要注意浮点数符号位的处理,需要调整分组的顺序。
- 预先转换类型:你每次循环都把
float转成unsigned int,可以考虑预先把整个数组转换成unsigned int数组,只做一次转换,而不是每次循环都转换,这样能节省大量的类型转换操作。比如:// 预先转换整个数组 unsigned int *uint_array = malloc(length * sizeof(unsigned int)); for (int j = 0; j < length; j++) { uint_array[j] = *(unsigned int *)&array[j]; } // 后续排序用uint_array来处理位运算 // 最后再转换回float(如果需要的话)
MSB优先的优劣
MSB优先的基数排序(通常叫"最高位优先")在某些场景下有优势,比如可以提前终止排序(当某个桶里的元素已经有序时),但对于大规模的数组来说,LSB优先通常更高效——因为LSB的实现更简单,内存访问更连续,缓存命中率更高。MSB需要递归或者更复杂的桶管理,反而会增加开销,所以你的LSB方向是对的,不用轻易切换到MSB。
其他小优化
- inline辅助函数:把
nthBit声明为inline函数,让编译器把它展开,避免函数调用的开销。 - 编译器优化选项:开启最高级别的编译器优化,比如GCC的
-O3 -march=native,让编译器自动做循环展开、指令重排等优化,这能带来非常明显的速度提升。 - 分离冗余计算:你的代码里在第一次循环时计算
sum,这部分可以移到排序外面,不要和排序逻辑混在一起,减少循环内的工作量。
内容的提问来源于stack exchange,提问作者Shane

