Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 209 – 多维数组

作者:
Paul Barrett <barrett at stsci.edu>, Travis Oliphant <oliphant at ee.byu.edu>
状态:
已撤回
类型:
标准跟踪
创建:
2001年1月3日
Python 版本:
2.2
历史记录:


目录

重要

此 PEP 已被撤回。

×

摘要

此 PEP 提出对多维数组模块 Numeric 进行重新设计和重新实现,以使其更容易向模块添加新功能。Numeric 2 将特别关注的方面包括高效访问大小超过 1GB 且由非同质数据结构或记录组成的数组。拟议的设计使用了四个 Python 类:ArrayType、UFunc、Array 和 ArrayView;以及一个低级 C 扩展模块 _ufunc,以有效地处理数组操作。此外,每种数组类型都有自己的 C 扩展模块,用于定义该类型的强制规则、操作和方法。这种设计使得能够以模块化的方式添加新的类型、功能和特性。新版本将引入一些与当前 Numeric 不兼容的地方。

动机

多维数组通常用于存储和处理科学、工程和计算中的数据。Python 目前有一个扩展模块,名为 Numeric(以下称为 Numeric 1),它为用户操作中等大小(约 10 MB)的同质数据数组提供了一套令人满意的功能。对于访问可能是非同质数据的更大数组(约 100 MB 或更大),Numeric 1 的实现效率低下且笨拙。将来,数值 Python 社区可能还会要求添加其他功能,正如 PEP 211:向 Python 添加新的线性运算符和 PEP 225:逐元素/逐对象运算符所示。

提案

本提案建议重新设计和重新实现 Numeric 1,以下称为 Numeric 2,这将使能够以简单且模块化的方式添加新的类型、功能和特性。Numeric 2 的初始设计应侧重于提供一个用于操作各种类型数组的通用框架,并应启用一种简单的机制来添加新的数组类型和 UFunc。然后,可以将特定于各个学科的功能方法分层构建在此核心之上。此新模块仍将称为 Numeric,并且将保留 Numeric 1 中找到的大部分行为。

拟议的设计使用了四个 Python 类:ArrayType、UFunc、Array 和 ArrayView;以及一个低级 C 扩展模块来有效地处理数组操作。此外,每种数组类型都有自己的 C 扩展模块,用于定义该类型的强制规则、操作和方法。在稍后的日期,当核心功能稳定后,一些 Python 类可以转换为 C 扩展类型。

一些计划的功能包括

  1. 改进内存使用

    此功能在处理大型数组时尤其重要,并且可以显著提高性能和内存使用率。我们已经确定了几个可以改进内存使用率的领域

    1. 使用局部强制模型

      Numeric 2 与 Numeric 1 一样,将实现局部强制模型(如 PEP 208 中所述),而不是使用 Python 的全局强制模型(该模型会创建临时数组),该模型将强制的责任推迟到运算符。通过使用内部缓冲区,如果需要,可以在操作时对每个数组(包括输出数组)执行强制操作。基准测试 [1] 表明,性能最多只会略有下降,并且在内部缓冲区小于 L2 缓存大小且处理器处于负载状态的情况下会得到改善。为了完全避免数组强制,Numeric 2 中允许使用具有混合类型参数的 C 函数。

    2. 避免创建临时数组

      在复杂的数组表达式(即包含多个操作的表达式)中,每个操作都将创建一个临时数组,该数组将在后续操作中使用然后删除。更好的方法是识别这些临时数组,并在可能的情况下重用其数据缓冲区,即当数组形状和类型与正在创建的临时数组相同。这可以通过检查临时数组的引用计数来完成。如果为 1,则操作完成后将删除它,并且可以作为重用候选。

    3. 可选使用内存映射文件

      Numeric 用户有时需要访问来自非常大的文件的数据或处理大于可用内存的数据。内存映射数组提供了一种机制来执行此操作,方法是将数据存储在磁盘上,同时使其看起来像在内存中一样。内存映射数组应通过消除文件访问期间两个复制步骤中的一个来改进对所有文件的访问。Numeric 应该能够透明地访问内存中和内存映射数组。

    4. 记录访问

      在某些科学领域,数据以二进制记录的形式存储在文件中。例如,在天文观测中,光子数据按到达时间顺序存储为光子的 1 维列表。这些记录或类似 C 的结构包含有关检测到的光子的信息,例如其到达时间、其在探测器上的位置及其能量。每个字段可能具有不同的类型,例如 char、int 或 float。此类数组引入了必须处理的新问题,特别是可能需要对数值执行字节对齐或字节交换才能正确访问(尽管字节交换也是内存映射数据的问题)。Numeric 2 旨在在访问或操作数据时自动处理对齐和表示问题。实现记录有两种方法;作为派生数组类或特殊数组类型,具体取决于您的观点。我们将此讨论推迟到“未解决的问题”部分。

  2. 其他数组类型

    Numeric 1 定义了 11 种类型:char、ubyte、sbyte、short、int、long、float、double、cfloat、cdouble 和 object。没有 ushort、uint 或 ulong 类型,也没有更复杂的类型,例如某些科学领域有用的位类型,以及可能用于实现掩码数组的类型。Numeric 1 的设计使得添加这些和其他类型成为一个困难且容易出错的过程。为了能够轻松添加(和删除)新的数组类型(例如下面描述的位类型),需要重新设计 Numeric。

    1. 位类型

      数组之间丰富比较的结果是一个布尔值数组。结果可以存储在 char 类型数组中,但这会不必要地浪费内存。更好的实现将使用位或布尔类型,将数组大小压缩八分之一。这目前正在为 Numeric 1(由 Travis Oliphant)实现,并且应该包含在 Numeric 2 中。

  3. 增强的数组索引语法

    向 Python 添加了扩展切片语法,以便通过允许步长大于 1 来在操作 Numeric 数组时提供更大的灵活性。此语法可以很好地用作规则间隔索引列表的简写。对于需要不规则间隔索引列表的情况,增强的数组索引语法将允许一维数组作为参数。

  4. 丰富比较

    Python 2.1 中 PEP 207:丰富比较的实现为操作数组提供了额外的灵活性。我们打算在 Numeric 2 中实现此功能。

  5. 数组广播规则

    当对标量和数组进行操作时,隐含的行为是创建一个新的数组,该数组具有与包含标量值的数组操作数相同的形状。这称为数组广播。它也适用于较低秩的数组,例如向量。此隐式行为在 Numeric 1 中实现,并且也将实现在 Numeric 2 中。

设计与实现

Numeric 2 的设计有四个主要类

  1. ArrayType

    这是一个简单的类,用于描述数组类型的基本属性,例如其名称、其大小(以字节为单位)、其相对于其他类型的强制关系等,例如

    Int32 = ArrayType('Int32', 4, 'doc-string')
    

    当导入该类型的 C 扩展模块时,定义了它与其他类型的关系。相应的 Python 代码为

    Int32.astype[Real64] = Real64
    

    这表示 Real64 数组类型比 Int32 数组类型具有更高的优先级。

    以下属性和方法是为核心实现提出的。可以根据具体情况添加其他属性,例如 .bitsize 或 .bitstrides 用于位类型。

    属性

    .name:                  e.g. "Int32", "Float64", etc.
    .typecode:              e.g. 'i', 'f', etc.
                            (for backward compatibility)
    .size (in bytes):       e.g. 4, 8, etc.
    .array_rules (mapping): rules between array types
    .pyobj_rules (mapping): rules between array and python types
    .doc:                   documentation string
    

    方法

    __init__():             initialization
    __del__():              destruction
    __repr__():             representation
    

    C-API:这仍然需要进一步详细说明。

  2. UFunc

    此类是 Numeric 2 的核心。它的设计类似于 ArrayType,因为 UFunc 创建了一个单例可调用对象,其属性为名称、总参数和输入参数的数量、文档字符串以及一个空的 CFunc 字典;例如

    add = UFunc('add', 3, 2, 'doc-string')
    

    定义时,add 实例没有任何与之关联的 C 函数,因此无法执行任何操作。稍后在导入数组类型的 C 扩展模块时,将填充或注册 CFunc 字典。register 方法的参数为:函数名称、函数描述符和 CUFunc 对象。相应的 Python 代码为

    add.register('add', (Int32, Int32, Int32), cfunc-add)
    

    在数组类型模块(例如 Int32)的初始化函数中,有两个 C API 函数:一个用于初始化强制规则,另一个用于注册 CFunc 对象。

    当对某些数组应用运算时,将调用 __call__ 方法。它获取每个数组的类型(如果未给出输出数组,则根据强制规则创建),并在 CFunc 字典中查找与参数类型匹配的键。如果存在,则立即执行操作,否则使用强制规则搜索相关操作和转换函数集。然后,__call__ 方法调用用 C 编写的 compute 方法来迭代每个数组的切片,即

    _ufunc.compute(slice, data, func, swap, conv)
    

    “func”参数是 CFuncObject,而“swap”和“conv”参数是需要预处理或后处理的数组的 CFuncObject 列表,否则使用 None。data 参数是缓冲区对象的列表,而 slice 参数给出了每个维度迭代次数以及每个数组和每个维度的缓冲区偏移量和步长。

    我们预定义了几个供 __call__ 方法使用的 UFunc:cast、swap、getobj 和 setobj。cast 和 swap 函数分别执行强制和字节交换,而 getobj 和 setobj 函数执行 Numeric 数组和 Python 序列之间的强制。

    以下属性和方法是为核心实现提出的。

    属性

    .name:                  e.g. "add", "subtract", etc.
    .nargs:                 number of total arguments
    .iargs:                 number of input arguments
    .cfuncs (mapping):      the set C functions
    .doc:                   documentation string
    

    方法

    __init__():             initialization
    __del__():              destruction
    __repr__():             representation
    __call__():             look-up and dispatch method
    initrule():             initialize coercion rule
    uninitrule():           uninitialize coercion rule
    register():             register a CUFunc
    unregister():           unregister a CUFunc
    

    C-API:这仍然需要进一步详细说明。

  3. Array

    此类包含有关数组的信息,例如形状、类型、数据的字节序等。它的运算符“+”、“-”等只调用相应的 UFunc 函数,例如

    def __add__(self, other):
        return ufunc.add(self, other)
    

    以下属性、方法和函数是为核心实现提出的。

    属性

    .shape:                 shape of the array
    .format:                type of the array
    .real (only complex):   real part of a complex array
    .imag (only complex):   imaginary part of a complex array
    

    方法

    __init__():             initialization
    __del__():              destruction
    __repr_():              representation
    __str__():              pretty representation
    __cmp__():              rich comparison
    __len__():
    __getitem__():
    __setitem__():
    __getslice__():
    __setslice__():
    numeric methods:
    copy():                 copy of array
    aslist():               create list from array
    asstring():             create string from array
    

    函数

    fromlist():             create array from sequence
    fromstring():           create array from string
    array():                create array with shape and value
    concat():               concatenate two arrays
    resize():               resize array
    

    C-API:这仍然需要进一步详细说明。

  4. ArrayView

    此类类似于 Array 类,只是 reshape 和 flat 方法将引发异常,因为非连续数组无法仅使用指针和步长信息来重新整形或展平。

    C-API:这仍然需要进一步详细说明。

  5. C 扩展模块

    Numeric2 将有几个 C 扩展模块。

    1. _ufunc

      此套件的主要模块是 _ufuncmodule.c。该模块的目的是做最基本的事情,即使用指定的 C 函数遍历数组。这些函数的接口与 Numeric 1 相同,即

      int (*CFunc)(char *data, int *steps, int repeat, void *func);
      

      并且它们的的功能预期也相同,即它们遍历最内层维度。

      以下属性和方法是为核心实现提出的。

      属性

      方法

      compute():
      

      C-API:这仍然需要进一步详细说明。

    2. _int32、_real64 等。

      每个数组类型还将有 C 扩展模块,例如 _int32module.c、_real64module.c 等。如前所述,当这些模块被 UFunc 模块导入时,它们会自动注册其函数和强制转换规则。这些模块的新版本或改进版本可以轻松地实现和使用,而不会影响 Numeric 2 的其余部分。

未解决的问题

  1. 切片语法是否默认使用复制或视图行为?

    Python 的默认行为是在使用切片语法时返回子列表或元组的副本,而 Numeric 1 返回数组的视图。Numeric 1 做出的选择显然是出于性能方面的考虑:开发人员希望避免在每次数组操作期间分配和复制数据缓冲区的开销,并认为需要对数组进行深度复制的情况很少见。然而,一些人认为 Numeric 的切片表示法也应该具有复制行为,以与 Python 列表保持一致。在这种情况下,可以通过实现写时复制来最大程度地减少与复制行为相关的性能损失。此方案使两个数组共享一个数据缓冲区(如视图行为),直到任一数组被分配新数据,此时才会创建数据缓冲区的副本。然后,视图行为将由 ArrayView 类实现,其行为类似于 Numeric 1 数组,即 .shape 对于非连续数组不可设置。使用 ArrayView 类还可以明确数组包含的数据类型。

  2. 项目语法是否默认使用复制或视图行为?

    项目语法也存在类似的问题。例如,如果 a = [[0,1,2], [3,4,5]]b = a[0],则更改 b[0] 也会更改 a[0][0],因为 a[0] 是 a 的第一行的引用或视图。因此,如果 c 是一个二维数组,则似乎 c[i] 应该返回一个一维数组,该数组是 c 的视图,而不是副本,以保持一致性。然而,c[i] 可以被认为只是 c[i,:] 的简写,这意味着假设切片语法返回副本的复制行为。Numeric 2 应该像列表一样返回视图还是返回副本。

  3. 标量强制转换是如何实现的?

    Python 比 Numeric 具有更少的数值类型,这会导致强制转换问题。例如,当将类型为 float 的 Python 标量与类型为 float 的 Numeric 数组相乘时,Numeric 数组将转换为 double,因为 Python float 类型实际上是 double。这通常不是期望的行为,因为 Numeric 数组的大小将加倍,这可能会很烦人,特别是对于非常大的数组。我们希望数组类型在相同类型类别(即整数、浮点数和复数)中优先于 Python 类型。因此,Python 整数与 Int16(短整型)数组之间的运算将返回 Int16 数组。而 Python 浮点数与 Int16 数组之间的运算将返回 Float64(双精度浮点数)数组。两个数组之间的运算使用正常的强制转换规则。

  4. 整数除法是如何处理的?

    在 Python 的未来版本中,整数除法的行为将发生变化。操作数将转换为浮点数,因此结果将为浮点数。如果我们实现提议的标量强制转换规则,其中数组优先于 Python 标量,那么将数组除以整数将返回整数数组,并且与 Python 的未来版本不一致,后者将返回类型为 double 的数组。科学程序员熟悉整数和浮点除法之间的区别,因此 Numeric 2 应该继续保持这种行为吗?

  5. 记录应该如何实现?

    根据你的观点,实现记录有两种方法。第一种是根据类型的行为将数组划分为不同的类。例如,数值数组是一类,字符串是第二类,记录是第三类,因为每个类的操作范围和类型都不同。因此,记录数组不是一种新类型,而是一种更灵活的数组形式。为了轻松访问和操作此类复杂数据,该类由具有不同字节偏移量到数据缓冲区的数值数组组成。例如,你可能有一个表,该表由 Int16、Real32 值的数组组成。两个数值数组,一个偏移量为 0 字节,步长为 6 字节,解释为 Int16,另一个偏移量为 2 字节,步长为 6 字节,解释为 Real32,将表示记录数组。这两个数值数组都将引用相同的数据缓冲区,但具有不同的偏移量和步长属性,以及不同的数值类型。

    第二种方法是将记录视为众多数组类型之一,尽管其数组操作比数值数组少,并且可能不同。这种方法认为数组类型是固定长度字符串的映射。映射可以很简单,例如整数和浮点数,也可以很复杂,例如复数、字节字符串和 C 结构。记录类型有效地将 struct 和 Numeric 模块合并到多维 struct 数组中。这种方法意味着对数组接口进行某些更改。例如,'typecode' 关键字参数可能应该更改为更具描述性的 'format' 关键字。

    1. 记录语义是如何定义和实现的?

      无论采用哪种记录实现方法,如果希望访问记录的子字段,都必须确定访问和操作记录的语法和语义。在这种情况下,记录类型本质上可以被认为是不均匀列表,例如 struct 模块的 unpack 方法返回的元组;并且记录的一维数组可以解释为二维数组,其中第二维是字段列表中的索引。这种增强的数组语义使访问一个或多个字段的数组变得容易和直接。它还允许用户以自然直观的方式对字段进行数组操作。如果我们假设记录作为数组类型实现,则最后一维默认为 0,因此对于由简单类型(如数值)组成的数组可以忽略。

  6. 掩码数组是如何实现的?

    Numeric 1 中的掩码数组作为单独的数组类实现。通过能够向 Numeric 2 添加新的数组类型,Numeric 2 中的掩码数组可以作为新的数组类型而不是数组类来实现。

  7. 数值错误是如何处理的(特别是 IEEE 浮点错误)?

    提案者(Paul Barrett 和 Travis Oliphant)不清楚处理错误的最佳或首选方法是什么。由于执行操作的大多数 C 函数都遍历数组的最内层(最后)维度。此维度可能包含一千个或更多项目,这些项目具有一个或多个不同类型的错误,例如除以零、下溢和上溢。此外,跟踪这些错误可能会以牺牲性能为代价。因此,我们建议几个选项

    1. 打印最严重错误的消息,让用户自行查找错误。
    2. 打印所有发生的错误及其发生次数的消息,让用户自行查找错误。
    3. 打印所有发生的错误及其发生位置的列表的消息。
    4. 或者使用混合方法,仅打印最严重的错误,但同时跟踪错误发生的内容和位置。这将允许用户查找错误,同时使错误消息简短。
  8. 简化 FORTRAN 库和代码集成的功能有哪些?

在这个阶段考虑如何简化 FORTRAN 库和用户代码在 Numeric 2 中的集成是一个好主意。

实施步骤

  1. 实现基本的 UFunc 功能
    1. 最小的数组类

      必要的类属性和方法,例如 .shape、.data、.type 等。

    2. 最小的 ArrayType 类

      Int32、Real64、Complex64、Char、Object

    3. 最小的 UFunc 类

      UFunc 实例化、CFunction 注册、用于一维数组的 UFunc 调用,包括执行对齐、字节交换和强制转换的规则。

    4. 最小的 C 扩展模块

      _UFunc,它在 C 中执行最内层的数组循环。

      此步骤实现了执行以下操作所需的一切:'c = add(a, b)',其中 a、b 和 c 是一维数组。它教会我们如何添加新的 UFunc、强制转换数组、将必要的信息传递给 C 迭代器方法以及执行实际计算。

  2. 继续增强 UFunc 迭代器和 Array 类
    1. 为 Array 类实现一些访问方法:print、repr、getitem、setitem 等。
    2. 实现多维数组
    3. 使用 UFunc 实现一些基本的 Array 方法:+、-、*、/ 等。
    4. 使 UFunc 能够使用 Python 序列。
  3. 完成标准的 UFunc 和 Array 类行为
    1. 实现 getslice 和 setslice 行为
    2. 处理数组广播规则
    3. 实现 Record 类型
  4. 添加其他功能
    1. 添加更多 UFunc
    2. 实现缓冲区或 mmap 访问

不兼容性

以下是 Numeric 1 和 Numeric 2 行为之间的一些不兼容性。

  1. 标量强制转换规则

    Numeric 1 对数组和 Python 数值类型有一组强制转换规则。这在计算数组表达式期间会导致意外和烦人的问题。Numeric 2 旨在通过有两组强制转换规则来克服这些问题:一组用于数组和 Python 数值类型,另一组仅用于数组。

  2. 没有 savespace 属性

    Numeric 1 中的 savespace 属性使设置了此属性的数组优先于未设置此属性的数组。Numeric 2 将没有此属性,因此正常的数组强制转换规则将生效。

  3. 切片语法返回副本

    Numeric 1 中的切片语法返回原始数组的视图。Numeric 2 的切片行为将是副本。你应该使用 ArrayView 类来获取数组的视图。

  4. 布尔比较返回布尔数组

    Numeric 1 中数组之间的比较会产生布尔标量,因为 Python 中的当前限制。Python 2.1 中丰富比较的出现将允许返回布尔数组。

  5. 类型字符已弃用

    Numeric 2 将有一个由 Type 实例组成的 ArrayType 类,例如 Int8、Int16、Int32 和 Int 用于有符号整数。Numeric 1 中的 typecode 方案将用于向后兼容,但将被弃用。

附录

  1. 隐式子数组迭代

    计算机动画由许多具有相同形状的二维图像或帧组成。通过将这些图像堆叠到单个内存块中,创建了一个三维数组。然而,要执行的操作并非针对整个三维数组,而是针对二维子数组集。在大多数数组语言中,必须提取每个帧、对其进行操作,然后使用类似 for 循环将其重新插入输出数组。J 语言允许程序员通过为帧和数组设置秩来隐式地执行此类操作。默认情况下,这些秩在数组创建期间将相同。Numeric 1 开发人员的意图是实现此功能,因为它基于 J 语言。Numeric 1 代码具有实现此行为所需的变量,但从未实现。如果 Numeric 1 中发现的数组广播规则不能完全支持此行为,我们打算在 Numeric 2 中实现隐式子数组迭代。

参考文献


来源: https://github.com/python/peps/blob/main/peps/pep-0209.rst

上次修改: 2024-04-14 13:35:25 GMT