【问题标题】:Array vs Record, Dynamic Subscripts vs Static Field数组 vs 记录,动态下标 vs 静态字段
【发布时间】:2021-11-09 21:39:19
【问题描述】:

所以,我使用 Robert W. Sebesta 的书“编程语言的概念”来学习编程语言。有一个有趣的段落将heterogeneous arrayrecord 进行比较。显然,与静态字段名称相比,下标是动态的,因此数组的访问时间要慢得多。

问题:那么为什么它更快?是不是跟 Stack 和 Heap 内存的使用有关系?

第 265 页,第 6 章:

当数据值的集合是异构的时使用记录 并且不同的字段不会以相同的方式处理。此外,该 记录的字段通常不需要按特定顺序处理。 字段名称类似于文字或常量下标。因为他们 是静态的,它们提供对字段的非常有效的访问。动态的 下标可用于访问记录字段,但它会 不允许类型检查,也会更慢。

【问题讨论】:

  • @Aivean 我编辑了它,你应该看到我所指的段落。
  • 它说'因为它们是静态的......'。静态是什么意思?
  • “静态”表示“在编译时已知”。即,如果您有一个“记录”point,其中包含字段xy。当您以point.y 访问它时,编译器知道y 的地址偏移与point 的内存地址相关。如果您不知道您访问的是哪个字段,即您有一个字符串变量field="y",然后调用point[field](Javascript 样式),编译器不知道您正在访问哪个字段并且必须添加运行时查找对象(或“记录”)的字段表中的内存偏移。请注意,这已大大简化。
  • 此外,必须检查动态下标以确保它们在边界内。
  • 你能澄清一下“异构数组”是什么意思吗?还有为什么下标被认为是动态的?实际上,如果您知道数组中某个字段的位置,那么索引就是静态索引。你能告诉我们更多关于上下文的信息吗(编译代码与解释代码、目标编程语言的种类等)。在性能方面,低级细节通常很重要。

标签: arrays performance programming-languages


【解决方案1】:

首先我想提一下,“性能”的概念并不真正脱离实际硬件和低级实现而存在。当本书讲述某种数据组织方式“更快”时,它会对实际的编译器/解释器实现和硬件(von Neumann architecture、CPU 指令集、内存布局)做出许多假设。

扯远了,让我们谈谈静态记录,同构和异构数组的实现中可能存在的差异。


1。静态记录

struct Point {
    int x;
    int y;
};

这是一个简单的 C++ 结构。当你这样做时:

Point *p = new Point();

内存分配器在堆上分配8个字节,并将这个块的开始地址存储在p中。

编译器预先知道Point 的大小(8 字节),因为Point 编译器的每个字段都知道它的大小和移位(即y 的大小为4 和移位4)。

当您访问 Point 的字段 (p -> x) 时,编译器将其替换为计算字段 x 的实际内存地址(p 的地址 + x 的移位)并访问此地址。没有任何运行时开销。


2。齐次数组

int *arr = new int[10];

这是一个简单的 C++ 同构数组。与struct类似,allocator在堆上分配4 * 10字节,并将其起始地址存储在arr中。

当你访问数组元素arr[i]时,编译器知道arr的地址,它的元素大小(它们是相同的,因为数组是齐次的),所以它可以计算你的内存地址'以arr + i * element_size 的身份重新访问(大多数架构上的一条 CPU 指令)。

同样,如果您不计算访问 iarr,那么开销并不大。


3。异构数组

现在,事情变得有趣了。异构数组没有直接的低级表示。有多种可能的实现可以将这个高级概念映射到实际的内存和机器代码中。

让我们考虑其中之一。

enum ElType {
    INT, POINT
};

struct ArrEl {
    ElType type;
    char* elPtr;
};

ArrEl *arr = new ArrEl[10];

arr[1].type = POINT;
arr[1].elPtr = (char*)(new Point {3,4} );

arr[2].type = INT;
arr[2].elPtr = (char*)(new int {2} );

注意,由于数组现在可以存储任何类型的元素(仅适用于本演示 intPoint):

  1. 数组元素可以有不同的大小(Point 是 8 个字节,int4),因此必须在堆上分配实际元素,并且数组只存储指针(替代方法将分配内存相等到每个元素的最大可能元素的大小,在一般情况下太浪费;注意:见下面的 cmets。
  2. 要了解存储元素的实际类型,必须存储其他元数据。这种方法选择将其存储在数组中,但大多数实际实现(包括python 和java)将其与实际对象一起存储为“对象头”。请参阅simplified implementation here

现在要访问此类数组的元素,必须检查此元数据:

ArrEl el = arr[2];

if (el.type == INT) {
  int *value = (int *) el.elPtr;
  std::cout <<  *value;
} else if (el.type == POINT) {
  Point *p = (Point *) el.elPtr;
  std::cout <<  p->x;
}

访问异构数组的开销包括额外的indirection:首先你必须访问数组的元素,然后按照指针指向堆上的实际值,并检查元数据。

存储所有指针和元数据也会产生额外的内存开销。当您将“简单”类型的同构数组(例如 int)与可以存储的异构数组(例如 intfloat)进行比较时,这一点尤其明显(注意:请参阅下面的 cmets)。


好的,现在,当我给出了异构数组在理论上会变慢的一般概念后,让我们来谈谈现实世界。

大多数用于具有异构数组的语言的现代 VM 都有JIT。与静态编译器(如 C++)相反,JIT 可以对执行的代码做出一些乐观的假设,如果这些假设失败,则在运行时将代码重新编译为更悲观的变体。

回到数组,虽然 Javascript 和 Python 等动态语言中的数组是异构的,但当数组以同构方式使用时,JIT 可能会将其编译为内部同构!例如,V8 certainly does that。我认为 Python 目前不会这样做,但将来可能会这样做。

此外,现代的optimizing compilers,包括static compilers,可以以你意想不到的方式重写你的代码。例如,您的代码创建一个对象并对其字段执行一些操作,但实际上编译器将用 CPU 寄存器替换字段访问。根本没有创建“实际”对象。

这就是为什么用实际基准验证关于性能的理论假设总是很重要的原因。


附:此演示中的所有 C++ 代码都不是惯用的、不安全的和糟糕的。不要在家里使用它。

【讨论】:

  • 请注意,值可以存储在“异构数组”中,而无需使用额外的间接寻址或动态分配。可以使用代数类型来做到这一点。在 C 中,可以使用联合来做到这一点。这假设类型列表在编译时是固定的(通常是这种情况)。这样,主要成本是由于(半)动态类型而需要的额外条件。
  • @JérômeRichard,你是对的。我简要提到了这种方法,即“分配的内存等于每个元素的最大可能元素的大小”,但由于在一般情况下不适用而将其丢弃。但是您是对的,如果仅用于大小大致相同的小型类型,它将具有最低的开销。
  • 我部分同意这个尺寸。请注意,许多分配器为每个分配的对象引入了内存开销(例如,通常为 8 个字节,并在对象之间添加一些填充,以便通常在 16 个字节上对齐对象)。在我的机器上,仅使用 4 字节整数的基于指针的实现需要 24 字节/整数。请注意,您只能为大型结构分配数据,以便联合类型仍然相对较小。分配通常很昂贵,并且经常导致内存扩散/碎片。像 V8 这样的一些 JIT 会进行这种优化:对象数据/指针存储在 double 值中。
  • @JérômeRichard,我没有想到这种混合方法。很高兴知道,谢谢你提出来。
猜你喜欢
  • 2012-05-09
  • 1970-01-01
  • 2010-12-04
  • 1970-01-01
  • 2021-09-25
  • 2019-06-29
  • 1970-01-01
  • 2017-11-05
  • 1970-01-01
相关资源
最近更新 更多