Browse Source

Migrate "cache unfriendly program & judging issues"

Xi Ruoyao 5 years ago
parent
commit
bc1d9cadab

+ 175 - 0
content/post/cache-unfriendly.md

@@ -0,0 +1,175 @@
++++
+date = 2018-08-01T18:50:00+08:00
+draft = false
+tags = ["cache", "xdoj-next"]
+title = "Cache Unfriendly Program and Judging Issues"
+summary = """
+Some problems and solutions have caused strange behavior of XDOJ judging
+system: when multiple solutions are executed simultaneously, the CPU time
+consumed by **each** solution grows linearly with the number
+of solutions. In this post we would reveal that these problems and
+solutions are cache unfriendly in nature, and it's the memory model of
+modern computers which leads to the behavior.
+"""
+
++++
+
+{{% alert note %}}
+Migrated from old blog, with minor changes.  If you can't read Chinese
+but are interested in this topic, mail me.
+{{% /alert %}}
+
+# 缓存不友好的程序及相关评测问题
+
+在 2016 年西电 ACM/ICPC 校赛的最后准备工作中,为了测试评测效率,
+本人写了一道题目(
+[XDOJ 1152](http://acm.xidian.edu.cn/problem.php?id=1152)
+)的效率极其低劣的[代码](/blog/files/cache-unfriendly/xdu1152_bad.cc)。
+提交这一代码后,果然得到了预期的上千毫秒的运行时间。
+于是本人开心地徒手把这个代码交了几次,结果令人吃惊的事情发生了:
+这些提交全都超时了!把时间限制改大,再进行测试,发现了下列经验规律:
+
+将 `xdoj1152_bad.cc` 编译后,若在 k 个进程中同时运行产生的目标代码,
+则**每个**进程耗费的用户态 CPU 时间都是单独在一个进程中运行目标代码所耗费的
+k 倍。
+
+这是非常诡异的,而且在实际评测中可能导致相同的提交有时通过,
+有时(服务器重载时)超时!
+于是[fpcsong](https://github.com/fpcsong)巨巨花了一个通宵进行调试,
+最终得到的结论是这一问题暂时无法解决,
+且在无数代码中只有这份代码会导致这一问题,因此就先这样进行比赛了。
+幸运的是,并没有人把代码写成我这样,因此没有触发这个问题。
+
+后来大家就把这件事忘了,不料在 2016 年的暑期集训中,这个问题又出现了。
+这道题目是 [XDOJ 1179](http://acm.xidian.edu.cn/problem.php?id=1179)。
+本人求解这一问题的代码在[这里](/blog/files/cache-unfriendly/xdu1179.cc)。
+注意这份代码并不像上次那样,是一份专门编写的效率低劣的代码,而是正确的解法
+其他选手解决这道题的代码也是大同小异的。结果在吃了两个答案错以后,
+本人发现输入数据有错,就呼叫命题人做了改正。之后裁判要进行重判,
+重判时自然是十几发针对这题的提交一起运行。于是,再一次地,所有提交都超时了。
+本人向裁判通知了上次出现的问题,并告诉他我也没有什么办法。
+最终裁判只得手工对所有提交一个一个重测。
+
+与上次故意折磨评测机不同,这次的奇怪现象完全是在对正常程序的评测中发生的。
+因此,必须找出这一现象的根源。我们提出了下列几个可能的原因:
+
+1. XDOJ 计时系统故障。
+2. Linux 内核计时系统故障。
+3. Glibc 在 I/O 操作中对某个系统共享资源进行轮询。
+4. 磁盘 I/O 拥堵。
+
+其中 4 首先被排除。在现代的 x86-64 计算机系统中,磁盘控制器都装备了 DMA ,
+磁盘 I/O 几乎不会消耗任何 CPU 时间。更重要的是,
+XDOJ 会把待测的程序和输入文件放在 tmpfs 中,
+所以在程序运行时根本就没有磁盘 I/O。
+
+然后是 1 ,用`/usr/bin/time`进行了类似的测试,
+同样得到并行环境下 CPU 时间变长的结果。可见,除非这两个计时系统同时故障,
+否则 XDOJ 就是运行良好的。
+
+至于 2 和 3,除非我们假设 Linus 和 Glibc 的开发者都是笨蛋,
+他们才不会解决这种明显的问题,而这是不可能的。
+
+于是所有假设都被毙掉了。为了找出真正的原因,我们使用 profiler
+对上述两段代码(下面以 `xdoj1152_bad.cc` 为例)进行计时分析。
+`gprof` 没有给出什么有用的信息,为了获取尽可能多的信息,
+采用 Linux 内核开发者提供的 `perf` 工具对代码进行采样分析,
+结果发现了问题。无论是单独执行一个进程,还是并发执行两个进程,
+每进程执行的机器指令条数都为 1.15 亿条左右。可见,Glibc 很好地完成了工作,
+并没有在并行条件下消耗更多的指令。
+然而,连 `perf` 工具本身都注意到了令人不安的事情,并用红字给出:
+
+单次执行:
+
+    2,178,115,949 stalled-cycles-frontend:u # 85.75% frontend cycles idle
+
+翻译成人话,就是有大量时钟周期未能进行有用的工作,因为流水线发生了阻塞,
+CPU 只能空转。那么,并行执行的情况如何呢?
+
+并行执行:
+
+    4,664,914,578 stalled-cycles-frontend:u # 93.31% frontend cycles idle
+    4,667,261,704 stalled-cycles-frontend:u # 93.28% frontend cycles idle
+
+看上去只提高了几个百分点,并没有什么问题,但是看一下前面的数就傻眼了:
+空转周期数增加了一倍以上!注意这样的空转是 CPU 内部的数字电路在空转,
+操作系统根本不知道发生了什么。在操作系统看来,每条指令执行的时间都变长了,
+它又不能强行打断指令进行调度。因此,空转的时间都是要算进进程 CPU 时间的,
+这就导致 CPU 时间的加倍。至于百分比的变化比较小,只是因为有用周期太少,
+空转周期的比例已经接近 100% ,自然增长缓慢了。
+
+那么,难道 Intel 的工程师都是笨蛋吗,
+为什么好好的双核 CPU (我的笔记本)跑两个进程就会导致这么多时钟周期空转呢?
+并不是这样的。我们用 `perf stat -d` 显示一下存储器访问事件,就会发现
+
+单次执行:
+
+	141,661,244 L1-dcache-load-misses:u # 123.13% of all L1-dcache hits
+
+也就是说,这程序执行的时候, L1 缓存几乎没有命中过。
+读一下代码也能直观地看到,该程序不断地将字符串复制到内存中几乎随机的位置,
+毫无局部性可言。`xdoj1179.cc` 也一样,不断对数组`f`进行跳跃式的访问,
+必然会导致大量缓存 miss。
+
+L1 miss 本身并不是什么可怕的问题,
+然而它揭示出了这两段“奇异”代码局部性差的本质。
+这样的代码几乎一定会继续导致 L2、L3 缓存的 miss 。
+此时 CPU 就必须从内存寻找数据填充缓存,于是两个核心开始争用内存总线,
+当一个核心访问数据时,另一个核心就要空转。
+这就导致大量时钟周期被白白浪费掉了。
+
+那么,如何解决这个问题呢?
+
+* 鸵鸟法,即假装这个问题不存在。
+对于 `xdoj1152_bad.cc` 这样的奇葩解法和暑期集训这种小规模提交来说,  
+几乎不会出现内存争用的问题。
+然而,如果像 XDOJ 1179 这样的题目出现在大规模比赛(如校赛)中,
+后果一定是灾难性的。
+
+* 改变计时规则,采用指令数估计“无干扰”的运行时间。
+初看上去这很好,然而编写常数更小的程序也是选手能力的一部分,
+如果只考虑指令条数,就为随便胡乱取模、
+编码完全不考虑局部性的选手大开方便之门,反而对了解缓存体系的选手不公平。
+
+* 采用多台评测机,每台一次只评测一个提交。现场赛就采用这种方法,
+而且把 XDOJ 服务器卖了,拿钱买十几台低端 PC 也不是什么困难的事情,
+但是这会导致 OJ 的开发和维护工作都变困难。
+
+* 采用 NUMA。NUMA 是解决这类内存争用问题的标准方案,即装配多套内存控制器,
+并将它们和 CPU 核心按照合理的拓扑结构进行互联。
+实际上现在的 XDOJ 服务器就是简单的 NUMA 结构,拥有两套内存控制器,
+分别与一半数量的 CPU 核心交互。当然,这么简单的结构是无法解决我们的问题的,
+它只是解决了在主板上安装两个 CPU 的问题。
+如果能够设计更加复杂和精巧的 NUMA 拓扑结构,就能很好地解决内存争用问题。
+然而,我们并没有钱购买强大的 NUMA 服务器。
+
+* ... ... 还是鸵鸟法吧。
+
+虽然我们无法彻底解决这一问题,但还是可以提供一些有用的提示:
+
+* 从出题人的角度来说,要注意减小标准解法的工作集,不要为了吓人强行加大数据。
+这样做只会增加缓存 miss,从而增大常数,反而可能导致暴力跑得都比正解快。
+
+* 从选手的角度来说,要注意减小自己程序的工作集。
+某些选手上来就把所有整数全写成 64 位,反正内存限制是 128MB 又爆不了,
+而且评测机是 64 位的,理论上 64 位算术和 32 位一样快。
+然而,这样工作集就会直接增大一倍。如果存在跳跃访问,这还会增大访问步长,
+从而增大跨越内存页面的访问,严重干扰硬件预取,更加恶化缓存性能。
+比如说,对于 `xdu1179.cc`,根据题目描述的限制,
+将数组 `f` 改为使用 8 位整数
+([xdu1179_better.cc](/blog/files/cache-unfriendly/xdu1179_better.cc)),
+可以将运行时间从 3.0s 降低到 1.8s 。
+
+* 从 OJ 开发和运维的角度来说,
+可以考虑[对某些题目强行串行评测][1]。另外,注意及时更新 CPU 微码,
+有时能够提高缓存命中率。
+
+* 从科研人员的角度来说,买工作站的时候要注意考虑内存总线带宽和缓存大小,
+而不是只数 CPU 核心和看主频。在进行计算时,
+要使用性能分析工具分析计算程序的工作状态,并调整系统参数或适当升级硬件,
+使之达到最佳状态。
+
+最后推荐 [Brendan Gregg 的一篇文章][2]。
+
+[1]: https://linux.xidian.edu.cn/git/XDU_ACM_ICPC/XDOJ-next/issues/13
+[2]: http://brendangregg.com/blog/2017-05-09/cpu-utilization-is-wrong.html

+ 41 - 0
static/files/cache-unfriendly/xdu1152_bad.cc

@@ -0,0 +1,41 @@
+#include <bits/stdc++.h>
+using namespace std;
+ 
+char name[1000001][30];
+char course[1000001][30];
+ 
+int main()
+{
+    int n, m;
+    while (scanf("%d%d",&n,&m)==2) {
+        for (int i = 0; i<=1000000; i++) {
+            name[i][0] = 0;
+            course[i][0] = 0;
+        }
+        scanf("%*s%*s");
+        for (int i = 0; i<n; i++) {
+            char buf[30];
+            int x;
+            scanf("%s%d", buf, &x);
+            strcpy(name[x], buf);
+        }
+        scanf("%*s%*s%*s");
+        for (int i = 0; i<m; i++) {
+            char buf[30];
+            int x;
+            scanf("%*s%d%s", &x, buf);
+            strcpy(course[x], buf);
+        }
+        puts("Name StuNum CourseName");
+        for (int i = 0; i<1000000; i++)
+            if (name[i][0]) {
+                printf("%s %d ", name[i], i);
+                if (course[i][0])
+                    puts(course[i]);
+                else
+                    puts("NULL");
+            }
+    }
+    return 0;
+}
+ 

+ 33 - 0
static/files/cache-unfriendly/xdu1179.cc

@@ -0,0 +1,33 @@
+#include <bits/stdc++.h>
+using namespace std;
+
+int f[100*100000+1];
+int val[5000];
+ 
+int main()
+{
+    int n, s;
+    while (scanf("%d%d", &n, &s)==2) {
+        memset(f, 0x3f, sizeof(f));
+        f[0] = 0;
+        for (int i = 0; i<n; i++) {
+            int v; scanf("%d", &v);
+            val[i] = v;
+            for (int j = 1; j<=s; j++)
+                f[j*v] = min(f[j*v], j);
+        }
+        int q; scanf("%d", &q);
+        for (int i = 0; i<q; i++) {
+            int ans = 101;
+            int m; scanf("%d", &m);
+            for (int j = 0; j<n; j++)
+                for (int k = 0; k<=(s+1)/2; k++) {
+                    if (m<val[j]*k) break;
+                    ans = min(ans, k+f[m-val[j]*k]);
+                }
+            if (ans>s) puts("-1");
+            else printf("%d\n", ans);
+        }
+    }
+    return 0;
+}

+ 33 - 0
static/files/cache-unfriendly/xdu1179_better.cc

@@ -0,0 +1,33 @@
+#include <bits/stdc++.h>
+using namespace std;
+
+uint8_t f[100*100000+1];
+int val[5000];
+ 
+int main()
+{
+    int n, s;
+    while (scanf("%d%d", &n, &s)==2) {
+        memset(f, 0x7f, sizeof(f));
+        f[0] = 0;
+        for (int i = 0; i<n; i++) {
+            int v; scanf("%d", &v);
+            val[i] = v;
+            for (int j = 1; j<=s; j++)
+                f[j*v] = min(f[j*v], (uint8_t)j);
+        }
+        int q; scanf("%d", &q);
+        for (int i = 0; i<q; i++) {
+            int ans = 101;
+            int m; scanf("%d", &m);
+            for (int j = 0; j<n; j++)
+                for (int k = 0; k<=(s+1)/2; k++) {
+                    if (m<val[j]*k) break;
+                    ans = min(ans, k+f[m-val[j]*k]);
+                }
+            if (ans>s) puts("-1");
+            else printf("%d\n", ans);
+        }
+    }
+    return 0;
+}