本文根据原英文博客 Small strings in Rust 完整翻译改写
起因
这篇文章源于一条推文:
应该有一篇文章来解释并对比 smolstr 和 smartstring(可能还有其他,比如 smallstr)。
作者接受了这个邀约,但按自己的规则来:
- 只对比前两个,不搞"可能还有其他"
- 允许至少三次题外话
事不宜迟,先建一个新项目:
bash
$ cargo new small
Created binary (application) `small` package
项目脚手架
这个小项目会不断扩展,所以先搭好框架。使用 argh 来解析命令行参数:
bash
$ cargo add argh
Adding argh v0.1.3 to dependencies
设置子命令结构,目前只有一个叫 sample 的子命令,放进独立模块:
rust
// src/main.rs
pub mod sample;
use argh::FromArgs;
#[derive(FromArgs)]
/// Small string demo
struct Args {
#[argh(subcommand)]
subcommand: Subcommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum Subcommand {
Sample(sample::Sample),
}
impl Subcommand {
fn run(self) {
match self {
Subcommand::Sample(x) => x.run(),
}
}
}
fn main() {
argh::from_env::<Args>().subcommand.run();
}
rust
// src/sample.rs
use argh::FromArgs;
#[derive(FromArgs)]
/// Run sample code
#[argh(subcommand, name = "sample")]
pub struct Sample {}
impl Sample {
pub fn run(self) {
todo!()
}
}
跑一下:
bash
$ cargo run -- sample
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/small sample`
thread 'main' panicked at 'not yet implemented', src/sample.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
框架搭好了,目前为止一切正常。
解析 JSON 数据集
今天的任务是:从 JSON 文件里解析出美国最大的 1000 个城市的列表。
使用 serde 和 serde_json,这件事变得非常简单:
toml
# Cargo.toml
[dependencies]
argh = "0.1.3"
serde = { version = "1.0.114", features = ["derive"] }
serde_json = "1.0.56"
数据集里包含很多信息:人口增长、地理坐标、人口数量、排名等。我们只关心城市名和州名:
rust
// src/sample.rs
impl Sample {
pub fn run(self) {
self.read_records();
}
fn read_records(&self) {
use serde::Deserialize;
#[derive(Deserialize)]
struct Record {
#[allow(unused)]
city: String,
#[allow(unused)]
state: String,
}
use std::fs::File;
let f = File::open("cities.json").unwrap();
let records: Vec<Record> = serde_json::from_reader(f).unwrap();
println!("Read {} records", records.len());
}
}
bash
$ cargo run -- sample
Read 1000 records
非常顺利。
追踪内存分配
接下来要关注的是:程序用了多少内存,以及发生了多少次分配和释放。
不用 Valgrind 的 Massif,而是自己写一个追踪分配器(Tracing Allocator)。
第一步:包装系统分配器
rust
// src/alloc.rs
use std::alloc::{GlobalAlloc, System};
pub struct Tracing {
pub inner: System,
}
impl Tracing {
pub const fn new() -> Self {
Self { inner: System }
}
}
unsafe impl GlobalAlloc for Tracing {
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
self.inner.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
self.inner.dealloc(ptr, layout)
}
}
这里用到的是 Rust stable,没有任何不稳定特性。
为什么
GlobalAlloc需要unsafe impl?不只是调用它的方法不安全,实现这个 trait 本身也是不安全的。文档里提到了几个原因,其中一条是: "如果全局分配器发生 unwind(即 panic),行为是未定义的。目前,这些函数中任何一个 panic 都可能导致内存不安全。"
然后让程序使用这个自定义分配器:
rust
// src/main.rs
#[global_allocator]
pub static ALLOCATOR: alloc::Tracing = alloc::Tracing::new();
注意:这里是
static,却调用了函数?因为
new()是const fn,从 Rust 1.31 开始稳定。不过截至 1.44,const fn内部能做的事还有限制,比如Default::default()和Into::into()都不是const fn。
验证一下程序是否还能正常运行:
bash
$ cargo run -- sample
Read 1000 records
正常。
第二步:让分配器说点有用的话
先试试 println!:
rust
unsafe impl GlobalAlloc for Tracing {
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
println!("allocating {} bytes", layout.size());
self.inner.alloc(layout)
}
// ...
}
bash
$ cargo run -- sample
^C
程序卡死了,无论是 debug 还是 release 都一样。卡在哪里?卡在尝试获取 stdout 的锁。
必须绕过 Rust 的标准输出机制,直接调用 libc:
bash
$ cargo add libc
Adding libc v0.2.71 to dependencies
写自定义分配器时有一个非常重要的约束:不能在分配器内部触发新的内存分配。否则分配器会递归调用自身,最终导致栈溢出。
所以这样写是不行的:
rust
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
let s = format!("allocating {} bytes", layout.size()); // format! 会分配内存!
libc::write(libc::STDOUT_FILENO, s.as_ptr() as _, s.len() as _);
self.inner.alloc(layout)
}
关于
as _不需要写出具体类型名,只要编译器能推断,
as _就够了。
运行结果:
bash
$ cargo run -- sample
[1] 94868 segmentation fault (core dumped)
format! 生成了一个 String,而 String 是堆分配的------正好触发了递归。
这样才是安全的:
rust
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
let s = "allocating!\n"; // 字符串字面量,存在二进制里,不涉及堆分配
libc::write(libc::STDOUT_FILENO, s.as_ptr() as _, s.len() as _);
self.inner.alloc(layout)
}
但只打印"allocating!"毫无信息量。
第三步:输出结构化的 JSON 事件
每次分配和释放,向 stderr 写出一条 JSON 对象,用 serde_json 序列化。serde_json 序列化时不需要堆分配,所以可以放心使用。
定义事件类型:
rust
// src/alloc.rs
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize)]
pub enum Event {
Alloc { addr: usize, size: usize },
Freed { addr: usize, size: usize },
}
写入辅助函数:
rust
// src/alloc.rs
use std::io::Cursor;
impl Tracing {
fn write_ev(&self, ev: Event) {
let mut buf = [0u8; 1024];
let mut cursor = Cursor::new(&mut buf[..]);
serde_json::to_writer(&mut cursor, &ev).unwrap();
let end = cursor.position() as usize;
self.write(&buf[..end]);
self.write(b"\n");
}
fn write(&self, s: &[u8]) {
unsafe {
libc::write(libc::STDERR_FILENO, s.as_ptr() as _, s.len() as _);
}
}
}
在 alloc 和 dealloc 中写入对应事件:
rust
unsafe impl GlobalAlloc for Tracing {
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
let res = self.inner.alloc(layout);
self.write_ev(Event::Alloc {
addr: res as _,
size: layout.size(),
});
res
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
self.write_ev(Event::Freed {
addr: ptr as _,
size: layout.size(),
});
self.inner.dealloc(ptr, layout)
}
}
把 stderr 输出重定向到文件(注意:不能再用 cargo run,否则 cargo 自身的输出也会混进来;zsh 里需要用 2>! 来覆盖已有文件):
bash
$ cargo build && ./target/debug/small sample 2>! events.ldjson
Read 1000 records
$ head -3 events.ldjson
{"Alloc":{"addr":93825708063040,"size":4}}
{"Alloc":{"addr":93825708063072,"size":5}}
{"Freed":{"addr":93825708063040,"size":4}}
第四步:添加开关
目前分配器从程序启动就开始记录,包括参数解析阶段。我们只想测量 JSON 解析部分,所以加一个开关:
rust
// src/alloc.rs
use std::sync::atomic::{AtomicBool, Ordering};
pub struct Tracing {
pub inner: System,
pub active: AtomicBool,
}
impl Tracing {
pub const fn new() -> Self {
Self {
inner: System,
active: AtomicBool::new(false),
}
}
pub fn set_active(&self, active: bool) {
self.active.store(active, Ordering::SeqCst);
}
}
unsafe impl GlobalAlloc for Tracing {
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
let res = self.inner.alloc(layout);
if self.active.load(Ordering::SeqCst) {
self.write_ev(Event::Alloc {
addr: res as _,
size: layout.size(),
});
}
res
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
if self.active.load(Ordering::SeqCst) {
self.write_ev(Event::Freed {
addr: ptr as _,
size: layout.size(),
});
}
self.inner.dealloc(ptr, layout)
}
}
只在 JSON 解析的前后打开和关闭分配器:
rust
// src/sample.rs
fn read_records(&self) {
// ...
use std::fs::File;
let f = File::open("cities.json").unwrap();
crate::ALLOCATOR.set_active(true);
let records: Vec<Record> = serde_json::from_reader(f).unwrap();
crate::ALLOCATOR.set_active(false);
println!("Read {} records", records.len());
}
bash
$ cargo build && ./target/debug/small sample 2>! events.ldjson
Read 1000 records
$ grep 'Alloc' events.ldjson | wc -l
2017
$ grep 'Freed' events.ldjson | wc -l
16
用 grep 和 wc 分析不够方便,来做一个更舒适的报告工具。
report 子命令
新增一个 report 子命令,用于分析事件文件:
rust
// src/main.rs
pub mod report;
#[derive(FromArgs)]
#[argh(subcommand)]
enum Subcommand {
Sample(sample::Sample),
Report(report::Report),
}
impl Subcommand {
fn run(self) {
match self {
Subcommand::Sample(x) => x.run(),
Subcommand::Report(x) => x.run(),
}
}
}
报告工具的需求清单:
- 统计峰值内存用量
- 统计总分配次数和释放次数
- 以 B、KiB 等单位格式化大小
- 画出内存用量随时间变化的折线图(就像 Massif 那样)
引入两个工具库:
bash
$ cargo add bytesize
Adding bytesize v1.0.1 to dependencies
$ cargo add textplots
Adding textplots v0.5.1 to dependencies
为什么要先把事件存文件,再用另一个子命令分析,而不是一次搞定?
因为在分配器内部收集事件到内存里非常棘手:
- 需要提前分配一块固定大小的缓冲区,要么放在静态存储里,要么直接调用系统分配器
- 需要处理同步问题------
GlobalAlloc的alloc和dealloc只接收&self,必须自己加锁- 而加锁的方案之前已经踩坑了(
println!卡死就是因为锁)分开两步虽然用起来稍微麻烦,但代码更简单。
报告工具的完整实现:
rust
// src/report.rs
use crate::alloc;
use alloc::Event;
use argh::FromArgs;
use bytesize::ByteSize;
use std::{
fs::File,
io::{BufRead, BufReader},
path::PathBuf,
};
use textplots::{Chart, Plot, Shape};
#[derive(FromArgs)]
/// Analyze report
#[argh(subcommand, name = "report")]
pub struct Report {
#[argh(positional)]
path: PathBuf,
}
trait Delta {
fn delta(self) -> isize;
}
impl Delta for alloc::Event {
fn delta(self) -> isize {
match self {
Event::Alloc { size, .. } => size as isize,
Event::Freed { size, .. } => -(size as isize),
}
}
}
impl Report {
pub fn run(self) {
let f = BufReader::new(File::open(&self.path).unwrap());
let mut events: Vec<alloc::Event> = Default::default();
for line in f.lines() {
let line = line.unwrap();
let ev: Event = serde_json::from_str(&line).unwrap();
events.push(ev);
}
println!("found {} events", events.len());
let mut points = vec![];
let mut curr_bytes = 0;
let mut peak_bytes = 0;
let mut alloc_events = 0;
let mut alloc_bytes = 0;
let mut freed_events = 0;
let mut freed_bytes = 0;
for (i, ev) in events.iter().copied().enumerate() {
curr_bytes += ev.delta();
points.push((i as f32, curr_bytes as f32));
if peak_bytes < curr_bytes {
peak_bytes = curr_bytes;
}
match ev {
Event::Alloc { size, .. } => {
alloc_events += 1;
alloc_bytes += size;
}
Event::Freed { size, .. } => {
freed_events += 1;
freed_bytes += size;
}
}
}
Chart::new(120, 80, 0.0, points.len() as f32)
.lineplot(Shape::Steps(&points[..]))
.nice();
println!(" total events | {}", events.len());
println!(" peak bytes | {}", ByteSize(peak_bytes as _));
println!(" ----------------------------");
println!(" alloc events | {}", alloc_events);
println!(" alloc bytes | {}", ByteSize(alloc_bytes as _));
println!(" ----------------------------");
println!(" freed events | {}", freed_events);
println!(" freed bytes | {}", ByteSize(freed_bytes as _));
}
}
先测一下使用标准 String 时的基线数据:
bash
$ cargo build && ./target/debug/small sample 2>! events.ldjson \
&& ./target/debug/small report events.ldjson
Read 1000 records
found 2033 events
[内存用量折线图,此处省略 ASCII 图形]
total events | 2033
peak bytes | 82.7 KB
----------------------------
alloc events | 2017
alloc bytes | 115.8 KB
----------------------------
freed events | 16
freed bytes | 49.2 KB
从折线图可以清晰地看到:曲线在几个点上骤升然后迅速回落。
原因很简单:程序在把 1000 条记录读入一个 Vec,但因为是流式读取,不知道要预留多大容量。每次 Vec 扩容时,它必须:
- 先分配
新容量字节的新内存 - 把旧数据复制过去
- 再释放旧内存
这些骤升的峰值,几乎可以肯定就是 Vec 扩容时产生的。
两次扩容峰值之间,内存用量平稳上升------这是每个 String 把内容存到堆上造成的,这也解释了为什么分配事件高达 2017 次。
尽量减少内存分配
最快的代码,是根本不执行的代码。分配越少,在分配器上花的时间就越少,性能自然就好。
如果输入文件不是很大,可以一次性全部读入内存,然后把字段反序列化为 &str 而非 String,让 serde 借用输入缓冲区里的字节:
rust
// src/sample.rs
fn read_records(&self) {
use serde::Deserialize;
#[derive(Deserialize)]
struct Record<'a> {
#[allow(unused)]
#[serde(borrow)]
city: &'a str,
#[allow(unused)]
state: &'a str,
}
crate::ALLOCATOR.set_active(true);
let input = std::fs::read_to_string("cities.json").unwrap();
let records: Vec<Record> = serde_json::from_str(&input).unwrap();
crate::ALLOCATOR.set_active(false);
println!("Read {} records", records.len());
}
测量结果:
bash
total events | 24
peak bytes | 293.4 KB
----------------------------
alloc events | 13
alloc bytes | 309.7 KB
----------------------------
freed events | 11
freed bytes | 32.7 KB
结果令人震惊:分配事件从 2017 次降到了 13 次,但峰值内存用量上升到了 293 KB------因为一次性把整个文件读进了内存。
注意:
bytesize默认以 1000 为进制,不是 1024。
对于当前这个小数据集,这个权衡完全可以接受。但如果输入文件有 100 GiB,甚至连读入内存都做不到,就必须回到流式方案。
此外,&str 有生命周期限制------它依赖于源缓冲区的生命周期,不能随意传给程序的其他部分。这种情况下,可以考虑用**字符串驻留(string interning)**作为折中方案,但这里不展开讨论。
接下来回到流式方案,用小字符串类型来优化。
smol_str:在栈上内联存储短字符串
smol_str 提供了一个 SmolStr 类型,它的大小和标准 String 相同,但可以把不超过 22 字节的字符串直接存储在结构体内部(栈上),无需堆分配。此外,它对纯空白字符串(若干换行加若干空格)有特殊优化。
需要注意的是:SmolStr 是不可变的 ,不像 String 可以修改。
超过 22 字节的字符串会退化为堆分配,和标准 String 一样。
引入依赖(带 serde feature):
toml
# Cargo.toml
smol_str = { version = "0.1.15", features = ["serde"] }
为了方便对比多种字符串实现,给 sample 命令增加一个 --lib 选项:
bash
$ cargo add parse-display
Adding parse-display v0.1.2 to dependencies
rust
// src/sample.rs
use parse_display::{Display, FromStr};
#[derive(FromArgs)]
/// Run sample code
#[argh(subcommand, name = "sample")]
pub struct Sample {
#[argh(option)]
/// which library to use
lib: Lib,
}
#[derive(Display, FromStr)]
#[display(style = "snake_case")]
enum Lib {
Std,
Smol,
Smart,
}
给 read_records 加一个泛型类型参数,用来切换字符串实现:
rust
impl Sample {
pub fn run(self) {
match self.lib {
Lib::Std => self.read_records::<String>(),
Lib::Smol => self.read_records::<smol_str::SmolStr>(),
Lib::Smart => todo!(),
}
}
fn read_records<S>(&self)
where
S: serde::de::DeserializeOwned,
{
use serde::Deserialize;
#[derive(Deserialize)]
struct Record<S> {
#[allow(unused)]
city: S,
#[allow(unused)]
state: S,
}
use std::fs::File;
let f = File::open("cities.json").unwrap();
crate::ALLOCATOR.set_active(true);
let records: Vec<Record<S>> = serde_json::from_reader(f).unwrap();
crate::ALLOCATOR.set_active(false);
println!("Read {} records", records.len());
}
}
测试 SmolStr:
bash
$ cargo build && ./target/debug/small sample --lib smol 2>! events.ldjson \
&& ./target/debug/small report events.ldjson
Read 1000 records
found 42 events
total events | 42
peak bytes | 73.9 KB
----------------------------
alloc events | 23
alloc bytes | 98.5 KB
----------------------------
freed events | 19
freed bytes | 49.2 KB
惊艳!
内存用量低于 String,分配事件从 2017 次降到了仅仅 23 次。
和 String 一样,折线图里可以看到 Vec 扩容时的峰值,但两次扩容之间,曲线是平的------说明美国最大 1000 个城市的名字,大多数都不超过 22 字节,全部内联存储了。
smartstring:更进一步
smartstring 和 smol_str 类似,但有几个区别:
- 最多可以内联存储 23 字节(多一个字节)
- 字符串类型是可变的
- 有两种策略,其中一种在字符串被修改到足够短之后会重新内联存储
bash
$ cargo add smartstring
Adding smartstring v0.2.2 to dependencies
写文章时 smartstring 还没有 serde feature,需要手写一个适配器:
rust
// src/sample.rs
use smartstring::{LazyCompact, SmartString};
struct SmartWrap(SmartString<LazyCompact>);
impl From<String> for SmartWrap {
fn from(s: String) -> Self {
Self(s.into())
}
}
impl From<&str> for SmartWrap {
fn from(s: &str) -> Self {
Self(s.into())
}
}
impl<'de> serde::Deserialize<'de> for SmartWrap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use ::serde::de::{Error, Visitor};
use std::fmt;
struct SmartVisitor;
impl<'a> Visitor<'a> for SmartVisitor {
type Value = SmartWrap;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string")
}
fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(v.into())
}
fn visit_borrowed_str<E: Error>(self, v: &'a str) -> Result<Self::Value, E> {
Ok(v.into())
}
fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
Ok(v.into())
}
}
deserializer.deserialize_str(SmartVisitor)
}
}
把 Smart 分支接上:
rust
impl Sample {
pub fn run(self) {
match self.lib {
Lib::Std => self.read_records::<String>(),
Lib::Smol => self.read_records::<smol_str::SmolStr>(),
Lib::Smart => self.read_records::<SmartWrap>(),
}
}
}
测试结果:
bash
$ cargo build && ./target/debug/small sample --lib smart 2>! events.ldjson \
&& ./target/debug/small report events.ldjson
Read 1000 records
found 35 events
total events | 35
peak bytes | 73.8 KB
----------------------------
alloc events | 19
alloc bytes | 98.4 KB
----------------------------
freed events | 16
freed bytes | 49.2 KB
目前为止最好的结果:分配事件降到 19 次 ,峰值内存用量 73.8 KB。
综合对比
功能特性对比
| 类型 | Serde 支持 | 最大内联字节数 | 流式友好 | 可变 | 是否使用 unsafe | Clone 复杂度 |
|---|---|---|---|---|---|---|
&'a str |
内置 | 借用,不涉及 | 否 | 否 | - | O(1) |
String |
内置 | 0(不内联) | 是 | 是 | - | O(n) |
SmolStr |
Feature | 22 | 是 | 否 | 无 | O(1) |
SmartString |
进行中 | 23 | 是 | 是 | 有 | O(n) |
写文章时,
smartstring的 serde 支持 PR 已经被合并。
内存分配对比(解析 1000 条 JSON 记录)
| 类型 | 峰值内存用量 | 总内存事件次数 |
|---|---|---|
String |
82.7 KB | 2033 |
SmolStr |
73.9 KB | 42 |
SmartString |
73.8 KB | 35 |
微基准测试
以下是三组微基准测试的结果。微基准测试有很大的误导性------它们完全忽略了分配次数的影响,也完全忽略了缓存局部性。即便如此,用来验证一些直觉还是有价值的。
注意:所有图表均使用对数坐标轴。 测试环境:Intel Xeon E5-1650 v2 @ 3.50GHz。
图例说明:
string→std::string::Stringsmol→smol_str::SmolStrsmart→smartstring::SmartString<LazyCompact>(不测试Compact,因为没有涉及修改操作)
基准一:从 &str 构建字符串
这是一个 O(n) 操作,所有类型都别无选择,必须把内容完整复制到自己的存储区。
对于超过 22 字节的长字符串,SmolStr 有轻微的常数开销------这不奇怪,因为 SmolStr 对长字符串使用的是 Arc<str>,而 Arc 带来了额外的引用计数操作。不过这也意味着 SmolStr 是 Send + Sync 的。
基准二:Clone
这是 smol_str 大放异彩的地方。
对于短字符串,SmartString 胜出。对于长字符串,SmolStr::clone 是 O(1)------因为只需要递增 Arc 的引用计数,完全不需要复制数据。
基准三:转换回 String
这个测试结果噪声比较大,可能有较多异常值。
对于短字符串,SmartString 和 SmolStr 都需要从头构建一个新 String,也就是分配内存、复制内容。对于长字符串,SmolStr 看起来做了两倍的工作------是不是分配了两次存储、复制了两次?作者也不确定,留待读者自行探索。
适用场景
这两个 crate 真正的使用场景,其实并不是解析 JSON 记录:
smol_str 被用于 rowan,而 rowan 被 rust-analyzer 使用。它的 README 说,主要使用场景是"作为典型编程语言 token 的足够好的默认存储方式"。它的空白字符特殊优化在本文中完全没有涉及。
smartstring 的推荐场景是"作为 B 树(如 BTreeMap)的键类型"------因为内联字符串可以显著提升缓存局部性,减少指针追踪。这一点在本文的任何基准测试里都没有体现。
小结
从这次对比中,可以得到一些实用结论:
- 如果能一次性读入整个输入,用
&str借用数据可以把分配次数压到接近最低(本例 13 次),但有生命周期限制,不适合需要长期持有字符串的场景。 - 如果需要流式处理且字符串通常较短,
SmolStr或SmartString都是String的有力替代,能把分配次数降低 100 倍。 SmolStr的 Clone 是 O(1),适合需要频繁共享字符串的场景(如 rust-analyzer 里的 token)。SmartString支持原地修改,适合需要可变字符串同时又希望避免短字符串堆分配的场景(如 BTreeMap 的键)。- 微基准测试只能提供有限的参考,真实场景中的缓存效应、分配器压力等因素往往更关键。