在双路服务器上,同一次读取可能要 80 ns 也可能要 130 ns,取决于页放在哪个插槽上。页放在哪里,是在它第一次被触碰时决定的。本讲为两个节点建模,说明一个线程独自初始化数据会让一半的工作变成远端访问,并让每个线程先触碰自己的那部分来解决它。
有距离的内存
大型服务器有两个处理器插槽,每个插槽有自己的内存条。核心直接读自己插槽上的内存;要读另一个插槽的内存,请求得穿过插槽之间的链路,花的时间更长。这样的机器叫NUMA。每个插槽连同它的内存构成一个节点,线程所在的节点就是它的核心所在的位置。在我们的模型里有两个节点,每个节点两个线程:线程 0 和 1 在节点 0,线程 2 和 3 在节点 1。本地访问 80 ns,远端访问 130 ns。
print([node_of_thread(thread, 2) for thread in range(4)])for thread_node in (0, 1): print("thread on node", thread_node, [LOCAL_NS if page_node == thread_node else REMOTE_NS for page_node in (0, 1)])
[0, 0, 1, 1] thread on node 0 [80.0, 130.0] thread on node 1 [130.0, 80.0]
每一行是一个线程的视角:自己节点上的页要付 80 ns,另一个节点上的页要付 130 ns。差的 50 ns 看似不多,但每一次到达内存的访问都要付这个差价。
谁决定页放在哪里
操作系统以页为单位给程序分配内存,并在页第一次被使用的时刻决定它放在哪个节点。默认规则是首次触碰:页被放到第一个触碰它的线程所在的节点上,并一直留在那里。另一种办法是把页在各节点间交错放置。下面的代码把这两种规则用在一小串写成(线程,地址)的访问上。
def first_touch_placement(trace, threads_per_node): placement = {} for thread, address in trace: page = address // PAGE_BYTES if page not in placement: placement[page] = node_of_thread(thread, threads_per_node) return placementdef interleaved_placement(trace, n_nodes): return {page: page % n_nodes for page in {address // PAGE_BYTES for _, address in trace}}trace = [(0, 0), (0, 4096), (3, 8192), (1, 4100)]print(first_touch_placement(trace, 2))print(interleaved_placement(trace, 2))
{0: 0, 1: 0, 2: 1}
{0: 0, 1: 1, 2: 0}第 0 页和第 1 页最先被位于节点 0 的线程 0 触碰,所以放在节点 0;第 2 页最先被节点 1 上的线程 3 触碰,所以放在节点 1。线程 1 之后对第 1 页的访问不会移动它。交错放置则不管谁碰了什么:第 1 页放在节点 1,仅仅因为 1 对 2 取模等于 1。
统计本地与远端访问
典型的并行任务把一张表切成块,每个线程一块。之后每个线程只读自己的那一块,理想情况下它读的每一页都在自己的节点上。下面的函数为 400 条记录、四个线程构造这样的访问模式,并统计三种放置方式下本地与远端访问的数量和耗时。第一种“single”:由一个线程初始化了全部数据,所以首次触碰把每一页都放到了节点 0。第二种:由各自的负责线程初始化它们自己的块。第三种:页交错放置。
def access_costs(trace, placement, threads_per_node): local = 0 remote = 0 for thread, address in trace: if placement[address // PAGE_BYTES] == node_of_thread(thread, threads_per_node): local += 1 else: remote += 1 return {"local": local, "remote": remote, "ns": local * LOCAL_NS + remote * REMOTE_NS}def partition_trace(n_records, n_threads, record_bytes): chunk = n_records // n_threads return [(thread, record * record_bytes) for thread in range(n_threads) for record in range(thread * chunk, (thread + 1) * chunk)]work = partition_trace(400, 4, 512)single = first_touch_placement([(0, address) for _, address in work], 2)owners = first_touch_placement(work, 2)spread = interleaved_placement(work, 2)for name, placement in (("single", single), ("owners", owners), ("interleaved", spread)): print(name, access_costs(work, placement, 2))
single {'local': 200, 'remote': 200, 'ns': 42000.0}
owners {'local': 400, 'remote': 0, 'ns': 32000.0}
interleaved {'local': 208, 'remote': 192, 'ns': 41600.0}只由一个线程初始化全部数据时,一半访问在远端,400 次读取要 42,000 ns。由各自负责的线程初始化自己的块时,没有任何远端访问,只要 32,000 ns,同样的程序快了大约 1.3 倍。交错放置在这里帮不上忙:每个线程读的是一整块连续数据,交错的页会把它一半的数据放到错误的节点上。交错放置是给那种每个线程到处都读的数据用的,这时没有哪种放置能对所有线程都好。
如果主线程在启动工作线程之前就分配并填好一个大数组,每一页的位置就都由它自己所在的节点决定了。程序是对的,分析器也看不出毛病,只是慢,而且插槽越多越慢。应该让每个工作线程自己初始化它要使用的那一部分。
在 Linux 上,numactl --interleave=all 把程序的页分散到各节点,numactl --cpunodebind 和 --membind 把线程和内存绑定到节点,numastat 显示有多少访问是本地的。交易系统通常把每个线程绑定到一个核心,并给它自己节点上的内存。
你要实现什么
实验沿着本讲走:线程所在的节点、两种放置规则、分块作业的访问模式、本地与远端访问的统计,以及对 SPY 记录的研究。
- 函数 · 问题依赖
- node_of_thread(thread, threads_per_node)线程运行在哪个节点?整数除法
- first_touch_placement(trace, threads_per_node)首次触碰把每一页放在哪里?node_of_thread
- interleaved_placement(trace, n_nodes)交错放置呢?简单算术
- partition_trace(n_records, n_threads, record_bytes)每个线程做哪些访问?简单算术
- access_costs(trace, placement, threads_per_node)本地和远端访问各多少次,耗时多久?node_of_thread
- study(bars)放置方式不当,在 SPY 记录上要付出多少代价?partition_tracefirst_touch_placementaccess_costs
下一个主题:字节放在哪里只是故事的一半,它们怎么被分发出去同样重要。在分配器与内存池里,程序会请求很多小对象,分配器发放它们的顺序和位置,决定了之后的遍历要碰到多少条缓存行。
实验:评分内容
6 个任务 · 100 分- 线程所在的节点5 分
- 首次触碰放置20 分
- 交错放置10 分
- 分块作业15 分
- 本地与远端访问25 分
- 基于 SPY 表的研究25 分