使用重新分配
-它可以比malloc+copy+free更高效
与C++不同,C的分配器API并不完全糟糕:它有重新分配
.
(不幸的是,没有便携式对齐的_分配
,但是malloc公司
/重新分配
为您提供足够的内存,可用于任何标准类型最大对齐时间
.MSVC有_对齐的_分配
和_对齐_分配
,但我不知道有POSIX或GNU等效程序。)
如果现有分配结束后有空闲内存,重新分配
只需增加现有分配,而无需复制任何内容。或者C库知道如何使用像Linux这样的系统调用mremap(mremap_MAYMOVE)
(请参见手册页),它可以将其移动到新的虚拟地址,而无需实际复制物理页面。(这可能只有在页面开始分配时才有效,但这就是glibcmalloc公司
用于大型分配。实际上,它保留了页面的前16个字节用于记帐数据,所以不幸的是,您得到了一个指针,该指针对齐16,但未对齐32,并且更宽。但没有不相关的数据表明埃玛先生
会从它应该的地方消失。)格利布重新分配
实际上使用mremap(mremap_MAYMOVE)
; 我与核实过斯特拉斯/a.输出
在为测试增加循环计数之后主要的
到20亿。希望是其他malloc公司
实现在它们使用的平台上也同样聪明。
除了复制时间外,alloc+copy的另一个缺点是需要同时分配两个副本。对于一个5GiB的int数组,您将暂时拥有10GiB的“脏”匿名页面,这迫使操作系统查找大量额外页面。充其量只是丢弃一些可能有用的磁盘缓存,最坏的情况是在从其他进程中分页时会破坏交换空间。
打电话重新分配
最糟糕的情况是手动调用malloc公司
/会员
/自由的
,但可以许多的更好,尤其是对于大型分配。不要落入C的陷阱++标准::矢量
陷入了增长时必须始终分配+复制+释放的困境。
作为GNU扩展,有一个重新登记
它检查乘法溢出,如分配
确实如此(但在增长时,新分配的空间不会为零)。(在glibc 2.26中提供。OpenBSD 5.6,FreeBSD 11.0。)Portable C应该手动执行此操作。
一些实现,包括GCC,将最大对象大小限制为PTRDIFF_max字节。如果您要手动检查,这可能是一个很好的限制,除非您真的关心如何弯曲实现限制,以及在32位程序中使用2GiB或更大的数组,例如,直到最大尺寸(_M)
(格利布)malloc公司
例如,如果您要求,32位x86 Linux将分配大于2GiB的阵列。在64位内核下,您拥有完整的4GiB地址空间。减法整数*
首先减去原始指针,然后进行算术右移以缩放大小(int)
(地脚螺栓),这样你可以得到一个负值ptrdiff时间
而不是例如10亿,但如果您不运行任何依赖于它的代码,则可能不会发生任何中断。)
@查克斯指出ptrdiff时间
可能比尺寸_t
在希望允许对象达到SIZE_ MAX字节的实现中。(这样的实现必须在减去指针、除法或右移之前有效地加宽指针,也许可以借助于具有进位标志的ISA。)
所以也许#定义DYNAMIC_ARRAY_MAXBYTES(SIZE_MAX/2)
作为实现定义极限的保守下限。我想如果你要用最大尺寸/2
,没有必要涉及PTRDIFF_最大值
,因为我怀疑有任何系统ptrdiff时间
窄于尺寸_t
但最大对象大小仍然是PTRDIFF_最大值
如果你想对此感到疑惑,#定义DYNAMIC_ARRAY_MAXBYTES((PTRDIFF_MAX<=SIZE_MAX/2)?PTRDIFF_MAX:SIZE_MAX/2)
如果需要,C实现可以任意设置一个较小的最大对象大小限制,但没有标准的宏来公开它。
带溢出和分配失败检查
#包括<stdint.h>#包括<stdbool.h>bool dynamic_array_push(DynamicArray*arr,int值){size_t cap=arr->容量;if(帽==arr->长度){上限*=2;//如果sizet的宽度至少与ptrdifftt的宽度一样,则不能溢出,因为它是无符号的。//但这在技术上并没有保证,所以在加倍之前可能会检查cap<PTRDIFF_MAX/sizeof(int)/2。(sizeof(int)==1在某些DSP上是可能的)如果(cap>PTRDIFF_MAX/sizeof(int)){cap=PTRDIFF_MAX/sizeof(int);//增长到典型C实现的最大对象大小if(盖<=arr->长度)返回false;}int*new_data=realloc(arr->data,cap*sizeof(int));if(!new_data){返回false;//原始数据仍然存在,但我们没有增长}arr->data=新数据;arr->容量=上限;}arr->data[arr->length]=值;arr->length++;返回true;}
我用了两次相同的常数(PTRDIFF_MAX/sizeof(int)
)而不是对照PTRDIFF_MAX/sizeof(int)/2
在容量翻倍之前。这有望帮助编译器像x86-64 GCC那样只将一个64位常量放入寄存器(地脚螺栓).
在返回的现有API中空隙
,你可以打电话中止()
或是失败,扼杀整个过程。这可能适合于一个简单的程序,但最好是通过类似于动态数组推送或中止
因此,失败原因行为的文档就在使用它的代码中,很容易在一个决定它有时想做其他事情的代码库中逐个搜索并开始替换它。
微基准测试:对于大型阵列,速度快2倍以上
将阵列大小从30亿增加到20亿整数
s(8 GiB),然后将其读回,使用重新分配
在我的系统上速度是原来的两倍多,页面错误减少了70倍,TLB未命中减少了9倍。(所有的页面错误都是“软错误”,而不是分页到磁盘。TLB未命中只是统计导致页面遍历的二级TLB未达标。)额外时间的很大一部分花费在内核中,将新页面归零
我在我的i7-6700k Skylake上使用双通道DDR4-2666 RAM,运行Arch Linux,内核6.5,用GCC 13.2.1编译,对这个旧版本进行了计时-O3-fno-plt
(否-三月
选项,尽管它不使用任何新指令,即使使用编译-march=本地
。每次推送的大小分支都会破坏填充模式的自动矢量化。)我的能量_性能_参考
是平衡性能
所以CPU只能运行3.9GHz。透明大页面已启用(与默认设置一样),碎片整理
是延迟+madwise
(我们不会马德维斯
调用,尽管我的系统有32 GiB RAM,但其中大部分是免费的,所以希望它能生成巨大的页面。)
$taskset-c 1 perf stat-etask-clock、上下文开关、cpu迁移、页面故障、周期、指令、uops_issued.any、dtlb_load_misses.miss_causes_a_walk、dtlb_store_misses.miss_causes.a_walk/发电机流量'的性能计数器统计信息/发电机-流量':5616.15毫秒任务时钟#已使用0.999个CPU17个上下文开关#3.027/秒0个cpu迁移#0.000/sec779398页故障#138.778 K/秒21628006730周期#3.851 GHz31442121299指令#1.45 insn/周期29540890921 uops_issued.any#5.260 G/秒1710792 dtlb_load_misses.miss_causes_a_walk#304.620 K/秒16175526 dtlb_store_misses.miss_causes_a_walk#2.880米/秒5.624499953秒3.514993000秒用户2.093666000秒系统
$taskset-c 1 perf stat-etask-clock、上下文开关、cpu迁移、页面故障、周期、指令、uops_issued.any、dtlb_load_misses.miss_causes_a_walk、dtlb_store_misses.miss_causes.a_walk/无发电机'的性能计数器统计信息/防发电机”:2459.69毫秒task-clock#1.000 CPU已使用8个上下文开关#3.252/秒0 cpu迁移#0.000/秒10601页故障#4.310 K/秒9517533410周期#3.869 GHz27308580581指令#2.87 insn/周期22551679420 uops_issued.any#9.169 G/秒1831019 dtlb_load_misses.miss_causes_a_walk#744.412 K/秒116378 dtlb_store_misses.miss_causes_a_walk#47.314 K/秒2.459971145秒1.57425.8万秒用户0.881689000秒系统
测试主要的
两者都是一样的。的起点三
意味着当我们退出时,我们离增长点只有一半的距离。(我只是随机地使用不同的值,这不是一个建议,只是我碰巧用它进行了测试。)
int main(无效){DynamicArray arr=dynamic_array_new(3);//输入一些测试值对于(int i=0;i<2000000000;i++){动态数组推送(&arr,i);}//测试数据输入是否正确for(int i=0;i<arr.length;i++){断言(i==arr.data[i]);}dynamic_array_cleanup(&arr);返回0;}
它进行的系统调用是:
…慢速版本brk(空)=0x560b599ad000brk(0x560b599ce000)=0x560b 599ce1000brk(0x560b599fe000)=0x560b 599fe1000mmap(空,200704,PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS,-1,0)=0x7f3717dd9000brk(0x560b599ce000)=0x560b 599ce1000mmap(空,397312,PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS,-1,0)=0x7f3717b87000蒙图(0x7f3717dd9000200704)=0mmap(空,790528,PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS,-1,0)=0x7f3717ac6000蒙图(0x7f3717b87000,397312)=0...
…快速版本brk(空)=0x5567b45c9000brk(0x5567b45ea000)=0x5567b 45ea000mmap(NULL,200704,PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS,-1,0)=0x7fbc9f7d4000#切换到mmap,此时必须复制mremap(0x7fbc9f7d4000,200704,397312,mremap_MAYMOVE)=0x7fbc9f582000mremap(0x7fbc9f582000、397312、790528,mremap_MAYMOVE)=0x7fbc9f4c1000mremap(0x7fbc9f4c1000,790528,1576960,mremap_MAYMOVE)=0x7fbc9f340000mremap(0x7fbc9f340000,1576960,3149824,mremap_MAYMOVE)=0x7fbc9f03f000...
Glibc的默认值malloc公司
从休息时的小分配开始(布雷克
). 这大概只是在继续重新分配
与alloc+复制导致一些堆转移(正如@Davislor的答案所描述的)相比,在空闲列表上留下一些分配的+脏空间,而不是返回给操作系统。(但哪些其他小的拨款可以使用。)所以这就是为什么我们看到两个布雷克
分配(在初始brk(空)
查找中断地址)从慢速版本与仅从重新分配
切换到之前的版本甲基丙烯酸甲酯
(复制一次,我想大小为150万B)。
有趣的是,埃玛先生
会继续移动虚拟地址,从而强制对阵列的现有部分执行TLB无效。由于我们的工作负载一直附加到最后,因此这些失效不会导致额外的TLB未命中,除非是在最后一次增长后的最终读取过程中(在这种情况下,当它太大时,阵列的起点可能已经从TLB中移出)。它是一个单线程进程,因此不需要对其他内核进行TLB停机(处理器间中断)。在其他情况下,不利因素可能更大。
检查一下可能会很有趣/proc/<PID>/映射
查看映射后是否有空闲的虚拟地址空间。如果没有,我们将看到@Davislor的回答中提到的堆转移效应,除非相反。如果有空间,那么不使用它就会错过优化。
对于512 MiB以上的大小,将增长因子降低到1.5可能是有意义的,尤其是在GNU/Linux等已知具有高效重新分配
可以使用分页技巧。或者,根据您的使用情况,甚至可以使增长线性化,而不是指数化地超过某个阈值,如1 GiB或10亿整数
的。更新N个可分页条目的工作量仍然是O(N),但常数因子是微小的但是,在每次使用推
,如果它像您希望的那样内联,以在非增长情况下提高效率。
可以进行部分内联:
在.h中静态内联//bool dynamic_array_push(DynamicArray*arr,int值){if(arr->容量==arr->长度){return dynamic_array_push__growth_needed(arr,value);//在单独的.c文件中定义}arr->data[arr->length]=值;arr->length++;返回true;}
这使得每个调用端的机器代码较小。它使每个调用方都成为非叶函数,但这已经是真的,因为增长路径调用重新分配
.
我们可以将增长代码拆分为单独的函数,而不是复制arr->data[arr->length]=值;
/arr->length++;
那里没有经过价值
作为一个隐语。但这是常见的推
在调用端是临时的且在推送后不需要的值。将其作为arg传递可以让调用方避免需要呼叫预留寄存器对于它,只需在一个call-clubered寄存器中计算它,因为它在函数调用后就死了。(我实际上还没有查看循环中的示例调用程序,看它有什么不同。这使得内联存储/增量代码的条件是内联比较,而不是__生长_需要
慢速路径,我想大概是相等的。简单的调用者只推送随机数或循环另一个数组,无论如何可能不太具有代表性。)