为什么 Knuth 使用这种笨拙的递减?

Why does Knuth use this clunky decrement?

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

获得

在一个阶段,struct 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 赋值,通常只会计算 mems 的一个增量。)

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

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

这整个实践似乎是基于对 C 工作方式的错误 idea/model,抽象机器执行的工作与执行的实际程序之间存在某种对应关系(即 "C is portable assembler" 谬误)。我不认为我们可以回答更多关于为什么会出现确切的代码片段,除了它恰好是一个不寻常的习惯用法,用于单独计算抽象机器上的加载和存储。

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

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

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

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

Timing. […] The running time of a program depends not only on the clock rate but also on the number of functional units that can be active simultaneously and the degree to which they are pipelined; it depends on the techniques used to prefetch instructions before they are executed; it depends on the size of the random-access memory that is used to give the illusion of 264 virtual bytes; and it depends on the sizes and allocation strategies of caches and other buffers, etc., etc.

For practical purposes, the running time of an MMIX program can often be estimated satisfactorily by assigning a fixed cost to each operation, based on the approximate running time that would be obtained on a high-performance machine with lots of main memory; so that’s what we will do. Each operation will be assumed to take an integer number of υ, where υ (pronounced “oops”) is a unit that represents the clock cycle time in a pipelined implementation. Although the value of υ decreases as technology improves, we always keep up with the latest advances because we measure time in units of υ, not in nanoseconds. The running time in our estimates will also be assumed to depend on the number of memory references or mems that a program uses; this is the number of load and store instructions. For example, we will assume that each LDO (load octa) instruction costs µ + υ, where µ is the average cost of a memory reference. The total running time of a program might be reported as, say, 35µ+ 1000υ, meaning “35 mems plus 1000 oops.” The ratio µ/υ has been increasing steadily for many years; nobody knows for sure whether this trend will continue, but experience has shown that µ and υ deserve to be considered independently.

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

Even though we will often use the assumptions of Table 1 for seat-of-the-pants estimates of running time, we must remember that the actual running time might be quite sensitive to the ordering of instructions. For example, integer division might cost only one cycle if we can find 60 other things to do between the time we issue the command and the time we need the result. Several LDB (load byte) instructions might need to reference memory only once, if they refer to the same octabyte. Yet the result of a load command is usually not ready for use in the immediately following instruction. Experience has shown that some algorithms work well with cache memory, and others do not; therefore µ is not really constant. Even the location of instructions in memory can have a significant effect on performance, because some instructions can be fetched together with others. […] Only the meta-simulator can be trusted to give reliable information about a program’s actual behavior in practice; but such results can be difficult to interpret, because infinitely many configurations are possible. That’s why we often resort to the much simpler estimates of Table 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 ), 所以好像在一行中写减量并没有为你节省任何 mems。特别是,将它算作一个是不正确的,尤其是像 cover 这样的算法,它在这个算法中被调用了很多次(精确覆盖的舞蹈链接)。

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