NULL 的存储方式

Polars 的 DataFrame 背后是 Apache Arrow 格式的数组,所以内存效率高、访问也快。Apache Arrow 是专为数据分析设计的列式内存格式,有四个特点。

  1. 内存效率:数据存放在连续的内存块里,对缓存友好,处理大量数据时占用也能降到最低。

  2. 兼容性:Arrow 和许多数据分析工具兼容,数据在工具之间搬动不必反复序列化。

  3. 快速访问:列式存储让“只取某几列”非常快,数据量大、又反复操作某几列时尤其明显。

  4. 数据类型丰富:Arrow 支持 NULL、列表、结构体、字符串等类型,复杂结构也能直接表达。

这一章说明 NULL、字符串、Array、List、Struct 这几种类型的内存结构。看懂这一层,就能明白为什么某些操作是“零拷贝”,以及为什么改一个值有时会复制整列。

内存布局之所以重要,是因为它决定了操作的代价:连续存放的数值列可以整块交给 SIMD 或并行任务,带索引的变长类型则要先查索引再切片。理解这些差别,才能在写代码时预判哪一步会复制、哪一步只是换一层包装。

import struct
import polars as pl

Polars 最底层的数据管理用的是 pyarrow 的 Buffer,读写都要经过这一层。理解这一层的关键是分清“对象”和“内存”:多个对象可以指向同一块内存。

Buffer 只描述一段连续内存的起始地址和长度,本身不拥有数据的所有权;Series、PyArrow 数组、NumPy 数组都可以是它的不同视图。下面的代码把 df['A'] 取两次,用 id() 比较这两个 Series 对象是否相同。

df = pl.DataFrame({
    "A": [1, 2, 3, 4],
    "B": [5, 6, 7, 8]
})

# 取两次 Series 对象
s1 = df['A']
s2 = df['A']
print(id(s1), id(s2))
2971281217872 2971250053968

结果是取到的两个 Series 对象并不相同。Polars 的 Series 内部是 Rust 实现的 PySeries,而这两个 PySeries 也是不同的对象。

也就是说,每次取列都会新建一层 Python 包装,但包装底下可能还是同一份数据。对象不同不代表数据不同:判断是否共享内存,要看底层 Buffer 的地址,而不是 id() 的返回值。

print(id(s1._s), id(s2._s))
2971278810192 2971273040768

把每个 Series 对象转成 PyArrow 数组,拿到的数组也并不相同,又是一层新的包装对象。

a1 = s1.to_arrow()
a2 = s2.to_arrow()
print(id(a1), id(a2))
2971251625888 2971282017248

再取 PyArrow 数组内部使用的 Buffer 对象:ID 不同,说明 Buffer 对象本身也不同,但用 address 属性看数据地址,会发现地址是同一个。

所以关系可以概括为“对象各不相同,底层内存共用”,下图把这层关系画了出来。

b1 = a1.buffers()[1]
b2 = a2.buffers()[1]
print(id(b1), id(b2))
print(b1.address, b2.address)
2971286646768 2971289146352
2969972706304 2969972706304

接着用索引操作改动 Series 的元素,再确认 Buffer 的数据地址。这一步要看的是:修改会不会影响别的对象。

s1[1] = 100
print(s1.to_arrow().buffers()[1].address)
2969972705664

结果数据的地址变了,说明这个操作没有原地覆写,而是把整列复制到新地址。

因此 s2 和 df 仍然持有原来的数据,看不到 s1 的改动。这就是“写时复制”(copy-on-write)的实际表现,也是 Polars 可以把列共享给多个对象的原因。

写时复制让“共享”和“不可变”可以同时成立:读取时多个对象共用同一块内存,只有真正要修改时才复制一份。代价是修改操作会比原地写入多一次分配,但换来的是更安全的数据共享和更容易推理的代码。

NULL 的存储方式#

Polars 用 PyArrow 的数据类型保存数据,NULL 的处理方式也由这套类型定义。含 NULL 的数组用两个 buffer(缓冲区)来存,一个记录“哪个位置是 NULL”,一个存放实际数据。

  • mask buffer(掩码缓冲):逐位记录各元素是否为 NULL,位为 1 表示非 NULL,为 0 表示 NULL。一个元素只占一位,所以数据量再大,NULL 信息也占不了多少。

  • data buffer(数据缓冲):存放实际数据,NULL 的位置用 0 或其它占位值填上,真正有值的元素连续存放。

这种“掩码 + 数据”的分工,让 NULL 不改变数值列的类型,也不打断连续存储;代价是每次读取都要同时检查掩码位。下面看具体例子。

s = pl.Series([1, None, None, 3, 4, None, 8, 9], dtype=pl.Int16)
a = s.to_arrow()
buf_mask, buf_data = a.buffers()

buf_mask 用位串表示 NULL 标记:0b11011001 的每一位对应一个元素,从最右一位(第 1 个元素)起依次是 1, 0, 0, 1, 1, 0, 1, 1。

由此可以看出,第 2、3、6 个元素是 NULL。位串是从右往左读的,初次接触时容易读反;先写下元素顺序,再逐位对照,就不容易出错。

bin(bytes(buf_mask)[0])
'0b11011001'
struct.unpack('<8h', buf_data)
(1, 0, 0, 3, 4, 0, 8, 9)

下面的代码确认 buf_data 和 s 共享同一块内存:用 ctypes 把 buf_data.address 转成指针 pbuf,通过它把第 4 个值改成 99,然后打印 s,看改动是否生效。

如果 s 里也变成 99,就说明两个对象确实指向同一块内存,这就是“零拷贝”的直接证据。

from ctypes import POINTER, c_int16, cast
pbuf = cast(buf_data.address, POINTER(c_int16))
pbuf[3] = 99
print(s)
shape: (8,)
Series: '' [i16]
[
	1
	null
	null
	99
	4
	null
	8
	9
]

Polars 的字符串列用三个 buffer 存数据。它不存指向变长对象的指针,而是把内容连续排在一起、再用索引切分,因此字符串的访问和操作都很快。

mask buffer(掩码缓冲):逐位记录各字符串元素是否为 NULL,位为 1 表示非 NULL,为 0 表示 NULL。

index buffer(索引缓冲):保存每个字符串元素的起始索引,第 i 个字符串的范围是 buf_index[i] 到 buf_index[i+1],从连续数据里一减就能切出来。

data buffer(数据缓冲):所有字符串连续存放在这里,内存占用降到最小。

这种结构和 Python 的字符串列表不同:Python 为每个字符串单独分配对象,Arrow 只分配一块连续内存和一组偏移量,因此缓存局部性更好,也更容易并行扫描。下面看具体例子。

s = pl.Series(["abc", "defghi", "xyz", None, "123"])
a = s.to_arrow()
buf_mask, buf_index, buf_data = a.buffers()

buf_mask 的每一位表示对应元素的 NULL 状态:前三个元素不是 NULL,第 4 个元素是 NULL。

bin(bytes(buf_mask)[0])
'0b11110111'

buf_index 里存的是 [0, 3, 9, 12, 12, 15],也就是各元素的起止索引。

  • "abc" 的起始索引是 0,结束索引是 3。

  • "defghi" 的起始索引是 3,结束索引是 9。

  • 该元素是 NULL,起止索引都是 12。

注意 NULL 那一段的长度是 0:它没有内容,所以在数据缓冲里不占位置。

struct.unpack('<6q', buf_index)
(0, 3, 9, 12, 12, 15)

buf_data 里所有字符串数据连续存放。借助索引缓冲,就能从这段连续数据里快速切出各元素,不必为每个字符串单独分配一块内存。

buf_data.to_pybytes()
b'abcdefghixyz123'

比如 s[2] 的起始索引是 9、结束索引是 12,从 buf_data 里取出的就是这一段:

buf_data[9:12].to_pybytes()
b'xyz'

Array#

Polars 的 Array 列存定长列表:每个元素长度相同,内存结构因此比 List 简单。

list_size 属性在这里尤其关键,它记录每个元素的长度;有了它就不需要索引缓冲,第 i 个元素的位置可以直接用 i * list_size 算出来。

定长的代价是灵活性:所有元素必须一样长,长度不齐时只能先用 NULL 补齐,或者改用 List。下面用例子说明。

s = pl.Series([
    [1,   None, 3   ], 
    [4,   5,    None], 
    [6,   7,    8   ], 
    [9,   10,   11  ]], 
    dtype=pl.Array(pl.Int16, 3))
a = s.to_arrow()

list_size 属性表示每个元素的长度。所有元素都一样长,所以只要一个数就能算出每个元素在缓冲里的位置。

print(f"{a.type.list_size=}")
a.type.list_size=3

buf_mask 和 buf_data 分别存 NULL 和数据,这两个 buffer 和一般列一样,区别在于数据按“元素 × list_size”排布。

_, buf_mask, buf_data = a.buffers()
bin(bytes(buf_mask)[0])
'0b11011101'
struct.unpack('<12h', buf_data)
(1, 0, 3, 4, 5, 0, 6, 7, 8, 9, 10, 11)

List#

Polars 的 List 列存变长列表:每个元素长度不同,所以除了数据和掩码,还需要一个索引缓冲来记录边界。

这个结构和字符串列非常像,都是“连续数据 + 索引切分”,理解 String 之后再看 List 会容易很多。区别在于 List 的数据缓冲里放的是子元素,而且子元素自己还有一层掩码。下面结合代码与输出说明这种结构。

s = pl.Series([
    [1, None, 3], 
    [10, 20], 
    None, 
    [100, 200, 300]], 
    dtype=pl.List(pl.Int16))
a = s.to_arrow()
buf_mask1, buf_index, buf_mask2, buf_data = a.buffers()

buf_mask1 逐位记录每个列表元素是否为 NULL:位为 1 表示非 NULL,为 0 表示 NULL。第 3 位是 0,说明第 3 个元素是 NULL。

bin(bytes(buf_mask1)[0])
'0b11111011'

buf_index 记录每个列表元素的起止索引,和字符串列一样,用它从连续数据里切出各元素。

s[0] 的数据索引是 0~3,对应 buf_data 里的 [1, 0, 3]。s[1] 的数据索引是 3~5,对应 [10, 20]。

struct.unpack('<5Q', buf_index)
(0, 3, 5, 5, 8)

还有一个 buffer 逐位记录列表内每个元素是否为 NULL。第 2 位是 0,说明第 2 个元素(None)是 NULL,这是“NULL 的 NULL”,与“元素本身是空列表”不是一回事。

bin(bytes(buf_mask2)[0])
'0b11111101'

所有列表元素的实际数据连续存在 buf_data 里,NULL 值用占位值(本例中是 0)表示,真正有没有值由内层掩码决定。

struct.unpack('<8h', buf_data)
(1, 0, 3, 10, 20, 100, 200, 300)

Struct#

Struct 列存带多个字段的数据:每个字段有自己的类型和内存结构,外层再加一层掩码记录“整行是否为 NULL”。

Struct 可以看成“按列组织的记录”:同一个字段的值连续存放,字段之间彼此独立;读取某个字段时,只需要访问该字段的缓冲区,不必把整行拆开。下面用例子说明 Struct 列的内存结构。

s = pl.Series([{"A": 1, "B": None}, {"A": None, "B": 20}, {"A": 3, "B": 30}, None])
a = s.to_arrow()
a.buffers()
[<pyarrow.Buffer address=0x2b380210a50 size=1 is_cpu=True is_mutable=False>,
 <pyarrow.Buffer address=0x2b380210a48 size=1 is_cpu=True is_mutable=False>,
 <pyarrow.Buffer address=0x2b3802b04e0 size=32 is_cpu=True is_mutable=False>,
 <pyarrow.Buffer address=0x2b380210a40 size=1 is_cpu=True is_mutable=False>,
 <pyarrow.Buffer address=0x2b3802b04a0 size=32 is_cpu=True is_mutable=False>]

每个字段用两个 buffer,一个存掩码,一个存值。从这种结构可以看出,各字段的值在内存里是连续存放的,这也是 Struct 比“多张表 join”更省内存的原因。

buf_mask_A, buf_value_A = a.buffers()[1:3]
print(bin(bytes(buf_mask_A)[0]))
print(struct.unpack('<4Q', buf_value_A))
0b101
(1, 0, 3, 0)
buf_mask_B, buf_value_B = a.buffers()[3:5]
print(bin(bytes(buf_mask_B)[0]))
print(struct.unpack('<4Q', buf_value_B))
0b110
(0, 20, 30, 0)