【问题标题】:Why does Knuth use this clunky decrement?为什么 Knuth 使用这种笨拙的递减?
【发布时间】:2019-05-27 12:28:18
【问题描述】:

我正在查看 Don Knuth 教授的一些代码,用 CWEB 编写,转换为 C。一个具体的例子是 dlx1.w,可从Knuth's website 获得

在某一阶段,结构 nd[cc] 的 .len 值会递减,并且以笨拙的方式完成:

  o,t=nd[cc].len-1;
  o,nd[cc].len=t;

(这是一个 Knuth 特定的问题,所以也许您已经知道“o”是一个用于递增“mems”的预处理器宏,它是通过访问 64 位字来衡量的运行总工作量.) "t" 中剩余的值绝对不会用于其他任何事情。 (这里的例子在dlx1.w的第665行,或者在ctangle之后的dlx1.c的第193行。)

我的问题是:为什么 Knuth 会这样写,而不是

nd[cc].len--;

他确实在其他地方使用过(dlx1.w 的第 551 行):

oo,nd[k].len--,nd[k].aux=i-1;

(而且“oo”是一个类似的宏,用于将“mems”递增两次——但这里有一些微妙之处,因为 .len 和 .aux 存储在同一个 64 位字中。将值分配给 S.len和 S.aux,通常只计算一次内存增量。)

我唯一的理论是递减由两次内存访问组成:首先是查找,然后是分配。 (对吗?)这种写法是对这两个步骤的提醒。这对 Knuth 来说会异常冗长,但也许这是本能的备忘录而不是说教。

对于它的价值,我在CWEB documentation 中进行了搜索,但没有找到答案。我的问题可能更多地与 Knuth 的标准做法有关,我正在一点一点地学习。我会对将这些实践作为一个整体进行布局(并且可能受到批评)的任何资源感兴趣——但现在,让我们专注于 Knuth 为何以这种方式编写它。

【问题讨论】:

  • Knuth 所做的一切都很笨拙,并且针对混淆进行了优化。
  • @Boann 我很好奇是什么让你这么说;你能详细说明一下吗?就我个人而言,我一直觉得 Knuth 的写作清晰而令人愉悦,从我第一次遇到(具体数学)到他的几篇论文和计算机编程艺术的部分内容。 (他的编程风格似乎与 1970 年代的主流有所不同,即他找到了与大多数其他人不同的解决方案,但不知道您指的是什么。)
  • @ShreevatsaR 听到有人这么说我很惊讶!我努力在 TAOCP 上取得任何进展,然后放弃并扔掉了这本书。对我来说,Knuth 是一个真正希望编程更少语言和更抽象的娱乐数学的人,所以他假装是那样的,尽管这(对我来说)完全令人困惑和不切实际。看看问题中那些荒谬的变量名; otndccaux。难以理解。
  • @Boann 你的经验是你的,我无法反驳,但 IMO Knuth 是最“现实世界”的数学家/算法家:最明确不是 假装抽象是真实的,但分析真实计算机上的真实程序可能会发生什么(例如,他不仅分析 Big-O 渐近线,还分析了常数因子)。当然,TAOCP 是关于算法的数学分析(见前言),但 IMO 它比它的任何继任者/替代品都更“具体”(例如,它们中的哪些包括用于研究缓存、RAM 大小、流水线等效果的汇编程序?)

标签: c increment decrement literate-programming knuth


【解决方案1】:

初步说明:对于 Knuth 风格的文学编程(即在阅读 WEB 或 CWEB 程序时),Knuth 设想的“真实”程序既不是“源”.w 文件,也不是生成的(纠结的)@ 987654328@ 文件,但排版(编织)输出。最好将源文件 .w 视为生成它的一种方式(当然还有提供给编译器的 .c 源文件)。 (如果你手边没有 cweave 和 TeX;我已经排版了其中一些程序here;这个程序DLX1 is here。)

所以在这种情况下,我会将代码中的位置描述为 DLX1 的模块 25,或子例程“cover”:

无论如何,回到实际问题:请注意,这 (DLX1) 是为计算机编程艺术编写的程序之一。因为每年报告一个程序“秒”或“分钟”所花费的时间变得毫无意义,他报告了一个程序花费了多少“mem”加上“oops”,这是由“mems”主导的,即对 64 位字的内存访问次数(通常)。所以这本书包含诸如“这个程序在 3.5 吉游戏的运行时间内找到这个问题的答案”这样的陈述。此外,这些陈述基本上是关于程序/算法本身的,而不是由特定版本的编译器为特定硬件生成的特定代码。 (理想情况下,当细节非常重要时,他会在 MMIX 或 MMIXAL 中编写程序并分析其在 MMIX 硬件上的操作,但这种情况很少见。)计算 mems(如上报告)是插入 ooo 指令进入程序。请注意,对于执行很多次的“内循环”指令(例如本例中的子例程 cover 中的所有内容),正确处理这一点更为重要。

这在第 1.3.1' 节(Fascicle 1 的一部分)中有详细说明:

时序。 […] 程序的运行时间不仅取决于时钟频率,还取决于可以同时激活的功能单元的数量以及它们的流水线化程度;它取决于用于在执行指令之前预取指令的技术;它取决于用于产生 264 个虚拟字节错觉的随机存取内存的大小;它取决于缓存和其他缓冲区等的大小和分配策略等。

出于实际目的,MMIX 程序的运行时间通常可以通过为每个操作分配固定成本来令人满意地估计,基于在具有大量主程序的高性能机器上获得的近似运行时间记忆;这就是我们要做的。假设每个操作采用整数 υ,其中 υ(发音为“oops”)是表示流水线实现中的时钟周期时间的单位。尽管 υ 的值随着技术的进步而降低,但我们始终跟上最新的进步,因为我们以 υ 为单位测量时间,而不是以纳秒为单位。我们估计的运行时间也将假定取决于程序使用的内存引用或内存的数量;这是加载和存储指令的数量。例如,我们将假设每条LDO(加载八进制)指令的成本为 µ + υ,其中 µ 是内存引用的平均成本。程序的总运行时间可能会报告为 35µ+ 1000υ,意思是“35 mems 加上 1000 oops”。 μ/υ比值多年来一直在稳步增加;没有人确切知道这种趋势是否会持续下去,但经验表明 µ 和 υ 值得独立考虑。

他当然明白与现实的区别:

尽管我们经常使用表 1 的假设来估计运行时间,但我们必须记住,实际运行时间可能对指令的顺序非常敏感。例如,如果我们在发出命令和需要结果之间找到 60 件其他事情要做,那么整数除法可能只需要一个周期。如果多个 LDB(加载字节)指令引用相同的八字节,则它们可能只需要引用一次内存。然而,加载命令的结果通常不能在紧随其后的指令中使用。经验表明,有些算法可以很好地处理高速缓存,而另一些则不行;因此 µ 并不是真正的常数。甚至指令在内存中的位置也会对性能产生重大影响,因为某些指令可以与其他指令一起获取。 […] 只有元模拟器可以提供有关程序在实践中的实际行为的可靠信息;但这样的结果可能难以解释,因为可能有无限多的配置。这就是为什么我们经常求助于表 1 中更简单的估计。

最后,我们可以使用 Godbolt 的Compiler Explorer 来查看典型编译器为这段代码生成的代码。 (理想情况下,我们会查看 MMIX 指令,但由于我们不能这样做,所以让我们在那里设置默认值,这似乎是 x68-64 gcc 8.2。)我删除了所有 os 和 oos。

对于代码的版本:

  /*o*/ t = nd[cc].len - 1;
  /*o*/ nd[cc].len = t;

第一行生成的代码是:

  movsx rax, r13d
  sal rax, 4
  add rax, OFFSET FLAT:nd+8
  mov eax, DWORD PTR [rax]
  lea r14d, [rax-1]

第二行是:

  movsx rax, r13d
  sal rax, 4
  add rax, OFFSET FLAT:nd+8
  mov DWORD PTR [rax], r14d

对于代码的版本:

  /*o ?*/ nd[cc].len --;

生成的代码是:

  movsx rax, r13d
  sal rax, 4
  add rax, OFFSET FLAT:nd+8
  mov eax, DWORD PTR [rax]
  lea edx, [rax-1]
  movsx rax, r13d
  sal rax, 4
  add rax, OFFSET FLAT:nd+8
  mov DWORD PTR [rax], edx

正如您所看到的(即使对 x86-64 程序集了解不多)只是前一种情况下生成的代码的串联(除了使用寄存器edx 而不是r14d),所以它不是如果在一行中写入减量可以为您节省任何内存。特别是,将其视为单个是不正确的,尤其是在像 cover 这样在该算法中被多次调用的东西中(跳舞链接以获得准确的覆盖)。

所以 Knuth 写的版本是正确的,因为它的目标是计算 mem 的数量。正如您所观察到的,他还可以写oo,nd[cc].len--;(计算两个内存),但在这种情况下乍一看可能看起来像一个错误。 (顺便说一句,在oo,nd[k].len--,nd[k].aux=i-1; 问题的示例中,两个内存来自负载和-- 中的存储;不是两个存储。)

【讨论】:

  • 好答案! ——感谢您的努力。我仍然不知道 Knuth 为什么不写“oo,nd[cc].len--;”。 (他可以假设读者会理解递减会花费两个内存的想法。)我接受这是一个小风格问题。 (当我第一次问的时候,我不确定它是否这么小。)
  • @EdWynn 是啊……另一个秘密:有时识字编程只是一种组织代码的方法,并不一定意味着程序已经应用了很多“吐槽”,即识字程序可以还是赶紧写,有bug等等。这里有各种各样的可能,例如当他写它时,他正在考虑其他事情,可能希望在两者之间添加更多指令,可能想要防止编译器优化掉立即发生的存储......可能没关系:-)
【解决方案2】:

这整个实践似乎是基于对 C 工作原理的错误想法/模型,即抽象机器执行的工作与执行的实际程序之间存在某种对应关系(即“C 是可移植汇编程序”的谬误)。我认为我们无法回答更多关于为什么会出现该确切代码片段的问题,除非它碰巧是一个不寻常的习惯用法,用于将抽象机器上的负载和存储分别计数。

【讨论】:

  • 我提出问题的一个动机是检查“笨重”方法是否不会产生不同的效果,也许是针对我无法想象的一些奇怪的边缘情况。 (S.len 和 t 都具有“int”类型,因此边缘情况的空间有限。)您专注于 mems 计数,这意味着效果只是简单的递减。好的,好的,我可以放松一下效果。
  • memcount 不需要基于正确的模型才能有用。它可以以一种与架构/编译器/系统无关的方式在实用上成为“所需工作”的代理度量。因此,它非常有用,我不知道有什么更好的方法。我最近在一些测试用例中将 memcounts 与“CPU 时间”进行了比较:这种关系是线性的。 (“CPU 时间”现在意味着比以往任何时候都少,所以我有效地测量了“没有其他用户进程的时钟时间”。)使用分支、预取和缓存,任何实际测量都高度依赖于系统,因此很难重现。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-09-16
  • 2017-09-05
  • 1970-01-01
  • 2023-03-05
  • 2015-01-03
相关资源
最近更新 更多