数据类型选择:用对类型,内存省一半

说到内存优化,有个最直接、也最容易被忽视的技巧——选对数据类型。

我个人习惯把数据类型选择放在项目第一个技术决策里。为什么?因为数据类型的选择决定了后续所有数组、矩阵、特征表的底子。底子选错了,后面怎么优化都是亡羊补牢。

以我处理过的一个宏基因组项目为例:原始fastq文件200GB,转换后的k-mer频率矩阵128GB。内存几乎爆掉。后来我把矩阵里的float64全部换成float16,内存直接从128GB降到32GB。你想想看,四倍的差距,换的只是数据类型而已。

核心原则:能用窄类型,别用宽类型。能用整数,别用浮点数。

整数类型:uint8、uint16、int32

宏基因组数据里最常见的整数是什么?计数。比如reads数、OTU丰度、k-mer出现次数。

计数一般不会太大。单样本的reads数通常几百万到几千万,但单个OTU的计数值往往只有几百到几千。对于这种场景,uint8足够了——它的范围是0到255,一个字节。

# 读取k-mer计数,原始数据是文本格式
with open("kmer_counts.txt", "r") as f:
    counts = [int(line.strip()) for line in f]

# 转换成uint8,内存直接缩小到原来的1/8
import numpy as np
counts_u8 = np.array(counts, dtype=np.uint8)

我见过不少人上来就用int32甚至int64,觉得“多总比少好”。其实没必要。我在做环境样本去重时遇到过一个问题:OTU表里95%以上的数值都在0-100之间,用int32纯属浪费。后来我改成uint8,内存占用降了75%,跑起来快多了。

当然要注意边界。如果你知道某个OTU在部分样本里可能出现上千次,那uint8会溢出。这时候用uint16(0-65535),两个字节,比int32还是省一半。

注意溢出:uint8最大值255。如果数据中可能出现超过255的计数,必须用uint16或更高类型。别为了省内存丢了数据。

浮点数类型:float16 vs float32 vs float64

宏基因组分析里浮点数用得最多的是归一化后的丰度和距离矩阵。这类数据精度要求不高,小数点后两三位就够用了。

float16,半精度浮点数,两个字节。范围大概是-65504到65504,精度大约3-4位有效数字。对于丰度比例(0.0001到1.0)、Beta多样性距离(0到1之间),float16完全够用。

# 原始距离矩阵是float64
import numpy as np
dist_full = np.random.rand(10000, 10000).astype(np.float64)
print(f"float64内存: {dist_full.nbytes / 1e9:.2f} GB")

# 转成float16
dist_half = dist_full.astype(np.float16)
print(f"float16内存: {dist_half.nbytes / 1e9:.2f} GB")
# 输出:float64: 0.80 GB → float16: 0.20 GB

你会问:精度损失会不会影响分析结果?我做过对比测试——用float64和float16分别计算PCoA的前三个轴,相关性超过0.999。说白了,宏基因组分析的结论很少依赖小数点后第五位。精度损失可以忽略。

但有一个坑要注意:float16不能做累加。如果你在循环里反复累加浮点数,float16的精度会迅速丢失。我的做法是:累加过程用float32,最后输出结果时转成float16存盘。

小技巧:可以用np.finfo(np.float16)查看float16的精度范围和eps。做到心里有数。

numpy和pandas里的数据类型声明

很多人在读数据时依赖pandas自动推断类型。pandas默认会用int64和float64。我建议你主动声明。

# 默认读法——内存杀手
df_default = pd.read_csv("otu_table.csv")

# 主动声明——内存节省
col_dtypes = {
    "sample_id": "category",
    "count": "uint16",
    "normalized_abundance": "float16"
}
df_opt = pd.read_csv("otu_table.csv", dtype=col_dtypes)

pandas的category类型对重复字符串特别有效。宏基因组里的taxonomy列、sample_id列,重复率很高,用category能省80%内存。

numpy也一样。创建数组时指定dtype,不要指望它猜。

# 默认float64
arr = np.zeros((10000, 10000))
print(arr.dtype)  # float64

# 主动指定float16
arr_half = np.zeros((10000, 10000), dtype=np.float16)

内存收益速查表

原始类型 优化类型 内存节省 适用场景
int64 uint8 8倍 小范围计数(0-255)
int64 uint16 4倍 中等范围计数(0-65535)
float64 float16 4倍 丰度、距离、比例(精度要求低)
float64 float32 2倍 中间计算、累加
str category 5-20倍 重复字符串(taxonomy, sample_id)

避坑:dtype转换的代价

我曾经遇到一个坑——一位同事直接拿float16跑k-mer频率矩阵的PCA。结果因为float16精度不够,协方差矩阵计算全错了。这里有个经验法则:计算用高精度,存盘用低精度。

具体来说:

  • 读数据时转成float16/uint8,节省加载内存
  • 计算前转成float32,保证精度
  • 结果写回文件前再转成float16
# 安全模式
data = load_data(dtype=np.float16)   # 读入时低精度,节省内存
data = data.astype(np.float32)        # 计算时升精度
result_pca = pca.fit_transform(data)  # 计算
result_pca = result_pca.astype(np.float16)  # 存盘降精度

数据类型的转换有开销,但和IO、计算时间相比几乎可以忽略。我测试过一个10万×10万的矩阵,两次astype的总时间不到0.3秒。

坑二:pandas的astype('float16')在部分版本里会报错。建议用numpy的dtype对象:pd.Series([1.0, 2.0], dtype=np.float16)。

内存映射与数据类型配合

如果你用numpy的memmap做内存映射,数据类型就更加重要了。因为mmap的dtype直接决定了操作系统的页缓存使用量。

# 用float64,每页(4KB)只能存512个值
mmap_64 = np.memmap('matrix.bin', dtype='float64', mode='r')

# 用float16,每页能存2048个值
mmap_16 = np.memmap('matrix.bin', dtype='float16', mode='r')

页缓存利用率高,随机访问性能就更好。我们后面专门讲mmap时还会深挖这个点。

架构图:数据类型选择的决策路径

下面这张图是我做项目时一直在用的决策路径。每次拿到新数据,我都会走一遍这个流程。

数据类型选择决策路径 原始数据 数据类型:整数还是浮点? 整数:uint8 / uint16 浮点:float16 / float32 范围判断:是否超过类型上限? 精度判断:是否满足分析需求? 选择最窄的安全类型 计算升精度+存盘降精度 原则:能小不小,能整不浮,计算升准,存盘降准

我个人习惯用这张图指导团队里的新人。每次项目开始前,先问三个问题:

  • 数据是整数还是浮点数?
  • 数值范围是多少?会不会溢出?
  • 精度要求有多高?分析结论是否依赖小数点后四位?
习惯养成:我建议你每次写代码时,先查一下数据的min/max和唯一值数量。一查就知道能不能用窄类型。多用.info()和.memory_usage(),养成“先看内存再看结果”的习惯。

数据类型选择的收益是乘法级别的。一个uint8矩阵的操作速度通常是int64的2-4倍——因为CPU在单位时间内能处理更多小数据。内存带宽节约了,缓存命中率提高了,整个pipeline都跟着跑起来。

嗯,这里要注意的是:别在代码里到处写dtype转换。我见过有人每个函数都转一次类型,结果CPU时间全花在astype上了。更好的做法是:在数据加载阶段统一处理好类型,后续计算都复用这个类型约定。

数据类型选择的本质是——理解你的数据,然后用最少的bit去描述它。宏基因组的数据量大,但信息密度低。我们只是用合适的容器装合适的数据而已。