Rust 中的小字符串:smol_str 与 smartstring 的对决

本文根据原英文博客 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 个城市的列表。

使用 serdeserde_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 _);
        }
    }
}

allocdealloc 中写入对应事件:

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

grepwc 分析不够方便,来做一个更舒适的报告工具。


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

为什么要先把事件存文件,再用另一个子命令分析,而不是一次搞定?

因为在分配器内部收集事件到内存里非常棘手:

  • 需要提前分配一块固定大小的缓冲区,要么放在静态存储里,要么直接调用系统分配器
  • 需要处理同步问题------GlobalAllocallocdealloc 只接收 &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 扩容时,它必须:

  1. 先分配 新容量 字节的新内存
  2. 把旧数据复制过去
  3. 再释放旧内存

这些骤升的峰值,几乎可以肯定就是 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:更进一步

smartstringsmol_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。

图例说明:

  • stringstd::string::String
  • smolsmol_str::SmolStr
  • smartsmartstring::SmartString<LazyCompact>(不测试 Compact,因为没有涉及修改操作)

基准一:从 &str 构建字符串

这是一个 O(n) 操作,所有类型都别无选择,必须把内容完整复制到自己的存储区。

对于超过 22 字节的长字符串,SmolStr 有轻微的常数开销------这不奇怪,因为 SmolStr 对长字符串使用的是 Arc<str>,而 Arc 带来了额外的引用计数操作。不过这也意味着 SmolStrSend + Sync 的。

基准二:Clone

这是 smol_str 大放异彩的地方。

对于短字符串,SmartString 胜出。对于长字符串,SmolStr::clone 是 O(1)------因为只需要递增 Arc 的引用计数,完全不需要复制数据。

基准三:转换回 String

这个测试结果噪声比较大,可能有较多异常值。

对于短字符串,SmartStringSmolStr 都需要从头构建一个新 String,也就是分配内存、复制内容。对于长字符串,SmolStr 看起来做了两倍的工作------是不是分配了两次存储、复制了两次?作者也不确定,留待读者自行探索。


适用场景

这两个 crate 真正的使用场景,其实并不是解析 JSON 记录:

smol_str 被用于 rowan,而 rowan 被 rust-analyzer 使用。它的 README 说,主要使用场景是"作为典型编程语言 token 的足够好的默认存储方式"。它的空白字符特殊优化在本文中完全没有涉及。

smartstring 的推荐场景是"作为 B 树(如 BTreeMap)的键类型"------因为内联字符串可以显著提升缓存局部性,减少指针追踪。这一点在本文的任何基准测试里都没有体现。


小结

从这次对比中,可以得到一些实用结论:

  • 如果能一次性读入整个输入,用 &str 借用数据可以把分配次数压到接近最低(本例 13 次),但有生命周期限制,不适合需要长期持有字符串的场景。
  • 如果需要流式处理且字符串通常较短,SmolStrSmartString 都是 String 的有力替代,能把分配次数降低 100 倍
  • SmolStr 的 Clone 是 O(1),适合需要频繁共享字符串的场景(如 rust-analyzer 里的 token)。
  • SmartString 支持原地修改,适合需要可变字符串同时又希望避免短字符串堆分配的场景(如 BTreeMap 的键)。
  • 微基准测试只能提供有限的参考,真实场景中的缓存效应、分配器压力等因素往往更关键。

原文:Small strings in Rust --- fasterthanli.me

相关推荐
一个做软件开发的牛马1 小时前
Java 常用类:String不可变、新时间API与包装类陷阱
java·后端
刀法如飞2 小时前
AI时代:一文搞懂DDD领域驱动设计
后端·架构·ai编程
weixin_468466852 小时前
Prometheus监控服务部署与实战指南
服务器·后端·python·docker·自动化·prometheus
会编程的土豆3 小时前
Go interface 底层的 itab 到底是什么
开发语言·后端·golang
candyTong3 小时前
Claude Code 每次调用 API 时,上下文是怎么"拼"出来的?
javascript·后端·架构
java_cj3 小时前
MySQL 执行原理深度剖析:查询成本计算与优化器内幕
数据库·后端·mysql
java_cj3 小时前
数据库范式化设计与性能优化全攻略
数据库·后端·性能优化·架构·开源
雪隐3 小时前
AI股票小助手01-量化交易基础概念
人工智能·后端·python
alwaysrun3 小时前
Rust之代数数据类型Enum
后端·rust·编程语言