我第一次被 Block Copy 的内存布局教育,是在一个网络数据搬运模块里。那时候我的思路很简单:把一段数据从源地址扔到目标地址,memcpy 一把梭。结果压测一上来,同样大小的数据,仅仅因为源缓冲区没有按 16 字节对齐,热点函数的耗时占比从 5% 直接跳到 15%。后来在存储引擎、图像行拷贝、std::deque 消息队列里反复踩坑,我才意识到一个被大多数人忽略的事实:真正决定 Block Copy 能不能直接用、能不能跑得快的,往往不是拷贝函数本身,而是源和目的内存布局。
这篇文章会把 Block Copy 和内存布局之间的关系彻底拆开讲清楚。我会聚焦 C/C++ 语境下最常见的“连续内存块复制”场景,也就是 memcpy/memmove 和它们的工程变体,同时把 std::deque 内存布局作为“分段连续”的典型对照——这也是最近被频繁讨论的话题。如果你正在写网络协议栈、图像处理、游戏服务器数据包组装、存储引擎,或者被各种“拷贝后崩溃、拷贝后性能差”的问题折磨,这篇内容应该能帮你建立一套“布局决定拷贝策略”的判断方法。
1. Block Copy 的常见场景与“内存布局”为何是首个取舍维度
1.1 三种最基础的连续内存块
所谓 Block Copy,最狭义的理解就是把一段连续内存从源地址复制到目标地址。这个过程能成立,前提是“这段内存可以由一个起始地址加一个长度完整描述”。在 C/C++ 里,最常见的连续内存块有三种来源:
- 栈上的数组,比如
int buf[1024]。它的起始地址就是buf,长度天然就是sizeof(buf),整个过程完全由编译器安排,不需要手动释放。 - 堆上的缓冲区,比如
malloc或new[]出来的内存。起始地址是返回的裸指针,长度需要你自己记录和传递。 - 静态/全局数组,生命周期贯穿整个程序,起始地址固定,长度是编译期常量。
这三种内存块之所以重要,是因为它们构成了绝大多数 Block Copy 操作的基础:要么拷贝它们自身,要么把不连续的数据搬进这种连续块中,做序列化、落盘或者发送。
判断一个区间是不是“连续块”,有一个很朴素的测试方法:你能否用“地址 + 长度”把它描述出来?能,它就是一块连续内存;不能,你就不得不考虑分段处理。
1.2 不连续布局:不能整块拷贝的常态
与连续块相对的,是各种“逻辑上连续、物理上分散”的数据结构。举几个最常见的:
std::deque:元素被分散在若干块定长缓冲区里,块与块之间地址不连续。std::list:每个节点单独分配,比 deque 更散。std::vector<std::vector<T>>:外层 vector 内部连续,但内层每个 vector 各自在堆上独立分配。- 链表、哈希表、树:节点之间通过指针连接,没有任何整块复制的前提。
在这些结构上做 Block Copy 时,最常见的错误就是直接 memcpy(&dst_container, &src_container, sizeof(src_container))。这种操作“拷贝”的只是容器对象的外壳,也就是容器内部的指针、迭代器状态、大小字段,并不会为源容器中的每个元素创建独立副本。结果是两个容器共享同一批底层资源,任何一方的析构、修改都可能造成双重释放或数据竞争。
很多人在这一步栽跟头后,第一反应是“我是不是该换成 memmove”或者“我是不是该加锁”,但真正的问题来源于内存布局:源对象根本不是一块连续内存,你却用连续内存的 API 去处理它。
1.3 判断布局的四个问题与决策表
我在实际项目里总结出一个四步判断法,每次动手写 Block Copy 前先过一遍,能省掉大量调试时间:
- 源和目标能否分别用一个“起始地址 + 长度”描述?不能,就需要循环、分块或换成 scatter/gather。
- 两个区间是否重叠?不保证不重叠,就必须用
memmove。 - 元素的类型是否平凡可复制(trivially copyable)?不保证,就不能用任何
mem*函数。 - 地址的对齐情况如何?未对齐会让部分 SIMD 宽加载无法使用,性能打折扣。
这四个问题可以组合成一个简单的决策表:
| 场景 | 内存布局 | 推荐做法 | 说明 |
|---|---|---|---|
| 整片连续、不重叠、平凡可复制 | 一段地址连续 | memcpy |
最理想,编译器/内存库会做优化 |
| 连续但可能重叠 | 一段地址连续 | memmove |
正确处理正反向复制 |
| 分段连续(如 deque/vector |
多段地址 | 循环 + 分段 memcpy |
无法一次整块处理 |
| 非平凡类型(如 string/vector) | 依赖具体实现 | 拷贝构造/移动语义 | 不能碰对象表示 |
| 多个不连续段发给 socket | 多段地址 | writev/sendmsg |
避免拼接大块,见 5.4 |
这套判断顺序是强制的:先确认布局,再选 API。很多人一上来就纠结 memcpy 和 memmove 的性能差异,但可能真正的坑在更靠前的布局判断上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次正确的 Block Copy:长度、对齐、重叠、生命周期
2.1 长度:sizeof 与 offsetof 决定拷贝边界
长度判断看似简单,实际上非常容易出错。核心点在于:sizeof 返回的是“对象在内存中占据的字节数”,这个数字包含了编译器为对齐而插入的 padding,它不一定等于各成员大小之和。
看一个典型的结构体:
cpp复制#include <cstddef>
struct Record {
int id; // offset 0
char name[40]; // offset 4
double score; // 对齐到 8,offset 48
};
name 数组从 offset 4 开始,占用 40 字节,到 offset 44 结束。但接下来的 double score 需要 8 字节对齐,所以编译器会从 44 填充到 48,再把 score 放在 48~56。整个结构体的大小是 56 字节,而不是 4 + 40 + 8 = 52 字节。
如果你要拷贝整个 Record 对象,用 sizeof(Record) 是绝对正确的,padding 里的未定义内容被拷贝过去并不违反规则。但如果你的本意是“只拷贝 name 字段”,却用 sizeof(Record) 或者从 name 起始地址拷贝 44 字节,就会多拷出 4 字节垃圾数据,甚至可能踩到边界。正确姿势是用 offsetof(Record, name) 定位字段起点,用 sizeof(Record::name) 作为长度。
这个例子说明:Block Copy 的“长度”不是一个拍脑袋的数字,它取决于你要拷贝的对象边界。拷贝整个对象用 sizeof,拷贝成员用 offsetof + sizeof(member),拷贝连续容器用 size() * sizeof(element_type),三者的语义完全不同。
2.2 对齐:从向量加载看未对齐地址的成本
对齐是另一个容易被忽略、但实际影响巨大的因素。CPU 的 SIMD 指令通常分两类:对齐加载和非对齐加载。
以 x86-64 上的 AVX 指令为例,_mm256_load_si256 要求目标地址 32 字节对齐,_mm256_loadu_si256 允许未对齐。早期的 SSE 时代,对齐加载在部分处理器上明显更快;现代 x86 处理器的未对齐加载惩罚已经小了很多,但在老一点的 ARM 核或某些低功耗芯片上,未对齐访问仍可能带来数倍的性能差异,甚至触发异常路径。
实际工程中,对齐问题的来源通常是:
malloc返回的地址至少按max_align_t对齐(一般是 16 字节),但没有保证 32 或 64 字节对齐。要做宽向量操作,得用posix_memalign、aligned_alloc或者std::align。- 在自定义字节流中拼接结构体时,如果你把数据按字节依次填充,下一个结构体的起始地址可能落在非对齐位置。
- 文件的 mmap 映射地址一般是页对齐的,但你在页中间偏移一段再读,就不一定满足 SIMD 对齐了。
解决思路分两层:一是分配时就对齐,这是最省事的。二是拷贝时主动处理头部未对齐字节,先把前几个字节拷完,使后续指针达到对齐边界,再用宽指令批量拷贝。
2.3 重叠:memcpy 与 memmove 的分工
memcpy 和 memmove 都做内存拷贝,但语义上有一个关键差异:memcpy 不允许源和目标区域重叠,memmove 允许。
底层原因也好理解:编译器/内存库为了追求性能,memcpy 可能会先拷贝前 32 字节,如果这时候源区域的一部分已经被目标覆盖,后续的数据就丢了或者被污染。memmove 则必须处理源地址和目标地址的相对位置:源地址低于目标地址时,从后往前拷贝;目标地址低于源地址时,从前往后拷贝,以此保证“被覆盖的源数据还没被读走”。
cpp复制char buf[64] = {0};
memcpy(buf + 8, buf, 32); // 未定义行为:源和目标重叠
memmove(buf + 8, buf, 32); // 安全:先判断地址高低再决定方向
性能上,memmove 通常只是在入口处多一次地址比较,然后在正确的方向分支上调用与 memcpy 几乎相同的宽拷贝逻辑。所以不要认为用了 memmove 就慢得离谱,也不要因为 memcpy 快就把重叠数据硬塞给它。正确性的优先级永远高于微优化:无法证明不重叠时,用 memmove;能保证不重叠且性能敏感时,用 memcpy。
2.4 可平凡复制:memcpy 的合法边界
很多新手会认为“内存不就是字节吗,memcpy 什么都能拷”。在 C 语言里这大体成立,但在 C++ 里不成立。
只有可平凡复制(trivially copyable)的类型才能用 memcpy/memmove 复制对象表示。C++ 标准规定:把非平凡类型的对象表示复制到另一块内存,再当成原类型使用,行为是未定义的。原因很简单:像 std::string、std::vector 这类类型内部持有堆资源指针,直接复制对象表示会让两个对象指向同一块堆内存,析构时必然出现双重释放。
如果你的代码里写了这样的东西:
cpp复制std::string a = "hello";
std::string b;
memcpy(&b, &a, sizeof(a)); // 灾难
那基本等于埋了一颗雷。正确做法是 b = a; 或者移动语义 b = std::move(a);。判断一个类型能不能 memcpy,最可靠的方法是 std::is_trivially_copyable_v<T>。在模板代码里,哪怕只有一瞬间不确定,也应该用这个概念保护起来。
3. std::deque 的内存布局详解:分段连续结构如何改变拷贝策略
3.1 中央 map 与分段缓冲区
std::deque 是最近讨论热度很高的容器,很多人只看名字以为它跟 vector 一样是连续内存,其实它的内存布局完全不同。deque 内部有两层结构:一根“指针数组”和若干块固定大小的缓冲区。
这根指针数组通常被称为中央 map(map),它的每个槽位都指向一块缓冲区。元素不是从某个起始地址线性排列的,而是散布在这些块中。为了支持头尾 O(1) 插入,数据块的填充往往从块的中间开始,向两侧扩展。所以 deque 的低地址端和元素逻辑顺序并不是简单一致的。
deque 的迭代器也因此不能只是一个裸指针。它至少需要携带两块信息:当前元素在块内的地址,以及当前块在中央 map 中的位置。每次迭代器跳转时,可能走到块的边界,需要跳到下一块的首地址。这比 vector 迭代器直接 ++ptr 要重得多。
理解这个布局后,很多现象都解释得通了:为什么 deque 的随机访问比 vector 慢?因为 deque[pos] 需要先定位块、再定位块内偏移,至少多一次间接寻址。为什么 deque 的首尾插入是 O(1)?因为只需要在两端未满的块里操作,满了再分配新块并把 map 槽位补上。
3.2 为什么 deque 不能整体 memcpy
这是我最想强调的一点。deque 的“逻辑连续”不代表“内存连续”,因此对它做 memcpy(&d2, &d1, sizeof(d1)) 是彻头彻尾的错误。你复制到的只是 deque 对象内部的指针、map 指针和迭代器状态,而不是元素本身。
两个 deque 对象共享同一批块缓冲区之后,后续几乎必崩:析构时双方都会释放同样的块,写操作时一方增长可能触发 map 重分配,另一方持有的旧 map 指针瞬间变成野指针。即使元素类型是 int 这种 POD,也一样不能这么做,因为 deque 的块归属是资源所有权问题,跟元素是否平凡可复制无关。
那么 std::copy(d1.begin(), d1.end(), dst) 是否会被标准库优化成一次整块 memcpy?在主流实现里是不会的。标准库通常通过迭代器的 iterator_traits 判断指针类型来启用 memcpy 优化,vector 的迭代器就是裸指针,所以 vector 到 vector 的拷贝会退化成整块内存复制;但 deque 的迭代器是一个自定义的随机访问迭代器,库函数无法获知各个块的起始地址和长度,只能逐个元素去赋值。即便元素是平凡类型,也没有捷径。
3.3 要快速把 deque 导出到连续内存该怎么做
如果你确实需要把 deque 的内容搬到一块连续内存里,比如做序列化、传给 C 接口、或者发给 socket,标准做法分两步:
- 用
d.size()提前知道元素数量,为目标连续缓冲区预留空间。 - 用
std::copy(d.begin(), d.end(), dst)或者基于范围的 for 循环逐个拷贝。
cpp复制std::deque<int> dq = {1, 2, 3, 4, 5};
std::vector<int> buf(dq.size());
std::copy(dq.begin(), dq.end(), buf.begin());
需要注意的是,deque 的块边界并不通过公开 API 暴露。你拿不到“每一块的起始地址和长度”,所以也不要试图写代码去猜内部布局来做分段 memcpy。有人会用 &d[0] 这种写法——这在 deque 上是违法的,因为 operator[] 返回的是迭代器解引用的结果,块之间并不保证地址连续,你拿到第一个元素的地址后继续做 +1 内存访问,只会读到块之间的空洞或无关数据。
如果你的场景是“频繁整体导出 deque 内容”,更务实的方案是在设计阶段就换容器。一个非常经典的替代品是 std::vector<T> + 头尾下标实现的环形队列:头尾插入删除都是 O(1) 均摊,内存天然连续,整块导出只需要一次 memcpy。deque 真正不可替代的优势是“随机位置的插入删除 + 首尾操作”,如果业务模式是“批量取走全部数据”,vector 环形队列通常更合适。
3.4 块分裂、迭代器失效与中间插入的真实代价
deque 的中间插入有一个实现层面的常见策略:当插入位置的块已满,把这块拆成两个块,将部分元素搬移到新块,然后在空出的位置插入。这个“块分裂”过程是按元素粒度搬移的,不是按块整体拷贝,所以中间插入的开销并不像很多人以为的那么低。
对于平凡类型,块分裂还能靠连续的块内 memmove 加速一部分;但对于 std::string 这类非平凡类型,就必须逐个调用移动构造/拷贝构造,代价更高。这也是为什么“deque 中间插入是 O(n)”这句话背后的实际成本,比理论复杂度更值得关注。
迭代器失效规则同样是内存布局的直接产物。deque 在首尾插入/删除时,元素引用通常保持有效,但迭代器可能因为中央 map 重分配而失效;中间插入/删除则会让所有迭代器和引用全部失效。原因在于:中间操作可能触发块分裂、元素搬移和 map 槽位变更,任何缓存了“块地址 + 块内偏移”的迭代器都可能指向错误位置。
写业务代码时,我的建议是:一旦对 deque 做了中间位置的操作,立刻丢弃所有旧迭代器,重新获取 begin()/end() 或通过下标访问。不要试图在容器变动的循环里继续使用旧迭代器,这是 deque 场景下最常见的 UB 来源之一。
4. 二维数组、图像 Buffer 与行优先布局下的 Block Copy 细节
4.1 连续二维数组与 vector 的布局差异
二维数组和二维 vector 看起来都像“矩阵”,内存布局却是天壤之别。
cpp复制int a[3][4]; // 12 个 int,48 字节连续排列
C/C++ 的二维数组本质上是“数组的数组”,整个对象是一块连续内存。你可以用 memcpy(dst, a, sizeof(a)) 一次性完整复制。这里的 sizeof(a) 会返回整块二维数组的字节数,非常方便。
std::vector<std::vector<int>> 则是另一回事。外层 vector 存储内层 vector 对象,这些对象内部又各自持有一个指向堆缓冲区的指针。每一行的数据都在堆上独立分配,行与行之间没有任何地址连续性保证。你无法通过一个 memcpy 复制整个矩阵,只能逐行去拷贝:
cpp复制std::vector<std::vector<int>> mat = ...;
for (size_t y = 0; y < mat.size(); ++y) {
memcpy(dst + y * cols * sizeof(int),
mat[y].data(),
mat[y].size() * sizeof(int));
}
这不仅是 API 选择问题,更是布局导致的必然结果。当你设计一个高性能矩阵结构时,优先考虑 std::array 固定大小二维数组,或者把数据放进单个 vector,用 y * cols + x 手工索引,而不是嵌套 vector。
4.2 行优先访问与 cache line 的实际影响
C/C++ 的二维数组采用行优先布局:a[i][j] 的地址等于基址加上 i * rowBytes + j * elemSize。同一行的元素在内存里相邻,同一列的元素在内存里相隔一整行。
这个布局对 Block Copy 和缓存命中率影响极大。64 字节的 cache line 可以容纳 16 个 int。如果你按行遍历矩阵,每加载一个 cache line 就能连续处理 16 个元素;如果你按列遍历,每处理一个元素,都要跳到距离很远的地址去加载另一个 cache line,缓存命中率断崖式下降。
我做过一个很典型的验证:4096x4096 的 int 矩阵,做转置。naive 版本按列写,性能惨不忍睹;改成 16x16 分块之后,读写都集中在 cache line 和 TLB 可覆盖的范围内,耗时差距能到 3~5 倍。这个例子和 Block Copy 的关系在于:分块转置本质上就是把“大范围不连续访问”拆成“小块连续访问”,让每次内存搬运都能更好地利用 cache。
cpp复制for (int i = 0; i < n; i += 16) {
for (int j = 0; j < n; j += 16) {
for (int x = i; x < i + 16; ++x) {
for (int y = j; y < j + 16; ++y) {
b[y][x] = a[x][y];
}
}
}
}
如果你要写一个和矩阵/图像打交道的模块,记住一句话:按行拷贝永远比按列拷贝快,连续范围越大越要用宽指令一次多拷几个元素。
4.3 Stride/Pitch:带填充行的图像 Buffer 拷贝标准姿势
图像缓冲区是 Block Copy 里非常容易踩坑的领域,因为它经常不是“整块连续可拷贝”的。
图像在内存里通常按行优先存放,但每一行的末尾可能有填充字节,用于让下一行的起始地址对齐到某个边界,比如 4、16、64 字节。这种情况下,实际行跨度(pitch/stride)会大于可见行宽 width * bytes_per_pixel。
如果你不知道 pitch 的存在,直接写:
cpp复制memcpy(dst, src, width * height * bpp);
大概率会得到一张花屏图像,因为拷贝过程中把行尾的填充字节也当成了像素数据;更严重的是源缓冲区每行末尾的填充位置可能不足以容纳你“以为”的行宽,直接越界读。
正确的标准姿势是逐行拷贝:
cpp复制for (int y = 0; y < height; ++y) {
memcpy(dst + y * dstPitch,
src + y * srcPitch,
width * bytesPerPixel);
}
只有当源和目标的 pitch 都等于 width * bytesPerPixel 时,才允许一次整块拷贝。很多底层 API,比如 CUDA 的 cudaMemcpy2D、各种图像库的 blit 函数,内部都是这个逻辑。它们不接收“整块起始地址 + 总长度”,而是接收“每行起始地址 + 每行长度 + pitch”,就是因为布局不允许你做一锤子买卖。
5. 高吞吐实测视角下的 Block Copy 优化经验
5.1 先让内存库帮你选:memcpy 的动态分派
在很多高性能场景,你的第一直觉可能是“我要自己写一个 SIMD 拷贝函数”。但我的经验是:先把 memcpy 用透,再考虑手写。
现代 C 运行库早就不是一份静态代码打天下了。以 glibc 为例,memcpy 通过 IFUNC 机制在进程启动时根据 CPU 特性分派到不同的实现:支持 AVX-512 的 CPU 走 AVX-512 路径,支持 ERMS 的走 rep movsb 路径,老 CPU 退回到 SSE2。你调用的 memcpy 符号,背后可能有好几套汇编级实现,覆盖了从几十字节到几 MB 的各种尺寸区间。
而且当拷贝大小是编译期常量时,编译器还会在调用点直接把 memcpy 内联成宽整型 load/store,根本不会真的进入 memcpy 函数。所以在绝大多数“连续内存复制”需求里,memcpy 就是最佳选择。
什么情况下你才需要往下挖?用 perf record 观察到热点确实落在 memcpy/memmove 本身,且拷贝尺寸非常固定、对齐条件很好、调用频率极高,这时候才值得考虑手动优化。否则,你大概率是在重复造轮子。
5.2 手动 SIMD 宽拷贝的收益与边界
如果你遇到了必须手动 SIMD 的场景,思路通常是:把一块连续内存分成“头部少量字节 + 中间 N 个向量 + 尾部少量字节”。头部和尾部用字节拷贝处理,中间用 _mm256_loadu_si256/_mm256_storeu_si256 这类宽指令一次搬运 32 字节。
这里有个边界需要说清楚:手动 SIMD 的收益更多来自于帮编译器消掉“运行时对齐检查”和“循环剩余部分”,而不是指令本身有多神奇。如果你的指针在编译期就能证明已对齐,-O3 下的 memcpy 内联代码几乎和你手写的一样。所以不要迷信“手写 AVX 就一定更快”,先确认你的块大小、对齐情况和调用频率是不是真的适合宽加载。
另一个容易被忽略的陷阱是:并不是向量宽度越大越好。AVX-512 在部分 CPU 上会触发频率下调,导致实际带宽收益被抵消。这也是 glibc 不盲目给所有 CPU 启用 AVX-512 版本的原因。做性能优化时,一定要以本机带宽测试为准,而不是以“指令数看起来更少”为准。
5.3 大块拷贝与 RFO:什么时候考虑 nontemporal store
普通 store 在写入一个不在缓存中的 cache line 时,处理器会先以读方式把这一行拉到缓存,再修改,这个过程叫 Read For Ownership(RFO)。大块拷贝时,RFO 会带来额外的内存读流量:你明明只想写 64 字节,为了写这 64 字节却先读了 64 字节,带宽被加倍消耗。
针对这个现象,x86 提供了 nontemporal store 指令,比如 _mm_stream_si128、_mm256_stream_si256。这些指令尽量绕过缓存,直接写内存,避免 RFO 的读放大。对于“拷贝超大块数据且拷贝完不会立刻读目标”的场景,比如把一份大文件内容搬进发送缓冲区、批量数据落盘,用 nontemporal store 往往能提升 10%~20% 的带宽。
使用时有三个前提:
- 目标地址尽量对齐到 cache line。
- 拷贝完短时间内不要读目标,否则绕过缓存反而导致后续读取很慢。
- 不要小步多次使用,比如每 64 字节调一次 stream 指令,开销会很大;要配合循环一次写多个 cache line。
我实测过一个 1GB 数据搬移场景,普通 memcpy 和 stream 版本差得并不悬殊,但如果是网络收包后直接拷贝到 DMA 缓冲区,nontemporal store 的收益会更明显,因为热路径上目标缓冲区几乎不会在用户态被再次读取。
5.4 不连续布局的终极优化:scatter/gather 与零拷贝
最后聊一个更有价值的思路:内存布局不连续时,别总想着“拼成一个连续块再拷贝”,很多场景可以直接用 scatter/gather 让内核帮你处理。
比如你手上有三个内存段,分别存储消息头、消息体、消息尾。常规做法是 memcpy 到一块连续缓冲区,再一次 write 发送。这个 memcpy 就是一次纯浪费的 Block Copy。改用 writev 或 sendmsg,把三个段的地址和长度填进 iovec 数组,一次系统调用就能把散落的数据按顺序发出去,省掉用户态的拼接拷贝。
cpp复制struct iovec iov[3];
iov[0].iov_base = header;
iov[0].iov_len = header_len;
iov[1].iov_base = body;
iov[1].iov_len = body_len;
iov[2].iov_base = tail;
iov[2].iov_len = tail_len;
writev(fd, iov, 3);
类似地,sendfile/splice 可以在内核态完成文件到 socket 的搬运,mmap 加上按需写回也能让整个用户态拷贝彻底消失。这些方案的本质是用系统的 scatter/gather 能力来表达“不连续布局”,而不是强行把布局改造成连续。
这给我们的工程启示是:在设计数据结构时,不仅要考虑“我怎么拷贝”,还要考虑“我能不能不拷”。连续布局当然好,但如果不连续是无法避免的,优先看操作系统是否已经提供了直接消费不连续段的接口,再决定要不要手写循环。
写了这么多年 Block Copy,我养成了一个习惯:任何拷贝代码的第一行不是 memcpy,而是一段注释,写清楚源和目标的内存布局。连续还是不连续、对齐还是未对齐、可平凡复制还是非平凡、是否重叠、要不要马上被读,这五个问题想清楚,具体用哪个函数往往顺理成章。
如果你现在正对着一个拷贝热点发愁,我的建议是别急着上 AVX,先把源和目标的内存布局图画出来看两分钟。很多性能问题在布局图面前根本藏不住——与其纠结一条指令,不如先想清楚那块内存长什么样。
