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

时间:2018-12-30 16:48:53

标签: c increment decrement literate-programming knuth

我正在看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用这种方式编写它。

2 个答案:

答案 0 :(得分:4)

初步说明:对于Knuth风格的读写编程(即,当阅读WEB或CWEB程序时),Knuth设想的“真实”程序既不是“源” .w文件也不是生成的(纠结的)文件).c文件,但排版(编织)输出。最好考虑使用源.w文件作为生成该文件的一种方式(当然,.c源文件也被馈送到编译器)。 (如果您没有方便的Cweave和TeX;我已经排版了其中一些程序here;这个程序DLX1 is here。)

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

question

无论如何,要回到实际的问题:请注意,此(DLX1)是为计算机编程艺术编写的程序之一。由于每年报告一个程序所花费的时间``秒''或``分钟''变得毫无意义,因此他报告了一个程序花费了多长时间的``内存''加``哎呀''数量,这主要由``内存''决定,即(通常)对64位字的内存访问次数。因此,本书包含诸如“该程序在运行时间为3.5千兆克的情况下找到此问题的答案”之类的语句。此外,这些语句从根本上讲是关于程序/算法本身的,而不是针对特定硬件的特定版本的编译器生成的特定代码。 (理想情况下,当细节非常重要时,他用MMIX或MMIXAL编写程序并在MMIX硬件上分析其操作,但这很少见。)计数内存(如上所述)是插入{{1}的目的。 }和o指令插入程序。请注意,正确执行多次“内部循环”指令(例如在这种情况下子例程oo中的所有内容)这一点更为重要。

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

  

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

     

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

他当然知道与现实的区别:

  

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

最后,我们可以使用Godbolt的Compiler Explorer来查看由典型编译器为此代码生成的代码。 (理想情况下,我们将查看MMIX指令,但由于无法执行该操作,让我们在该位置使用默认值,似乎是x68-64 gcc 8.2。)我删除了所有LDO和{{1 }}。

对于带有以下代码的版本:

o

第一行生成的代码是:

oo

第二行是:

  /*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
您可以看到的

(即使对x86-64汇编一无所知)只是在前一种情况下生成的代码的串联(除了使用寄存器 /*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 之外),因此,并不是说将减量写成一行就可以节省任何内存。特别是,将其计为一个是不正确的,尤其是在类似edx之类的算法中,这种算法被称为“多次”(为准确覆盖而跳舞的链接)。

因此,Knuth编写的版本对于计算内存数量的目的是正确的。正如您所观察到的,他还可以写r14d(包括两个内存),但是在这种情况下乍看起来可能像个错误。 (顺便说一句,在您的示例cover中,这两个内存来自负载和oo,nd[cc].len--;中的存储;不是两个存储。)

答案 1 :(得分:3)

整个实践似乎基于一个错误的想法/模型,即C的工作方式,即抽象机执行的工作与所执行的实际程序之间存在一定的对应关系(即“ C是可移植汇编程序”谬误) )。我不认为我们能对为什么出现确切的代码片段有更多的答案,只是它恰好是一个不寻常的习惯用法,用于将抽象计算机上的负载和存储作为独立的计数。