https://gendignoux.com/blog/2025/03/03/rust-interning-2000x.html
Guillaume Endignoux
Toggle navigation
* Home
* Blog
* Research
* Programming
* Reading list
The power of interning: making a time series database 2000x smaller
in Rust
rust perf json
March 3, 2025
by Guillaume Endignoux
@gendx | RSS
This week-end project started by browsing the open-data repository of
Paris' public transport network, which contains various APIs to query
real-time departures, current disruptions, etc. The data reuse
section caught my eye, as it features external projects that use this
open data. In particular, the RATP status website provides a really
nice interface to visualize historical disruptions on metro, RER/
train and tramway lines.
Screenshot of the RATP status website A usual day of disruptions on
ratpstatus.fr.
Under the hood, the ratpstatus.fr GitHub repository contains all the
JSON files queried from the open-data API, every 2 minutes for almost
a year now. A repository with 188K commits and more than 10 GB of
accumulated data at the last commit alone (as measured by git clone
--depth=1) is definitely an interesting database choice! To be clear,
this post isn't in any way a critique of that. RATP status is an
excellent website providing useful information that runs blazingly
fast^1 and smoothly without the usual bloat you see on the web
nowadays.
Nonetheless, the 10 GB of data got me to wonder: can we compress that
better, by spending a reasonable amount of time (i.e. a week-end
project)? In this deep dive post, I'll explain how I used the
interning design pattern in Rust to compress this data set by a
factor of two thousand! We'll investigate how to best structure the
interner itself, how to tune our data schema to work well with it,
and likewise how serialization can best leverage interning.
If you've got lots of JSON files accumulating in your storage, you
should read on!
---------------------------------------------------------------------
* Importing the data (135%)
* Interning
+ Strings (47%)
+ Arbitrary types (7.6%)
+ Dropping the reference (2.8%)
* Tuning the schema
+ Sorting sets (1.5%)
+ Using enums (1.4%)
+ Splitting structs (0.82%)
+ Specializing types (0.64%)
* Serialization
+ Writing custom (de)serializers with Serde (0.29%)
+ Compression and fighting [DEL:the Rust borrow checker:DEL]
Linux pipes (0.05%)
+ Tuple encoding
+ Optimizing sets revisited
* Final result: a lightweight append-only database
Importing the data (135%)
The first step of this experiment was to import the source data. To
give a bit more context, each data point was a JSON file with many
entries looking like this.
{
"disruptions": [
{
"id": "445a6032-d1ca-11ef-b3f5-0a58a9feac02",
"applicationPeriods": [
{
"begin": "20250113T180000",
"end": "20250228T230000"
}
],
"lastUpdate": "20250113T172013",
"cause": "PERTURBATION",
"severity": "BLOQUANTE",
"title": "Activities in Aincourt",
"message": "
Due to work in Aincourt, the Centre and Eglise stops will not be served in both directions of traffic on line 95 15 and in the direction of Magny en Vexin Gare Routiere only on line 95 44. From 13/01 until further notice.
Please refer to Les Cadenas stops"
},
...
}
Let's import this data into our program! If you're not familiar with
Rust, this programming language makes it very easy to deserialize
data from all sorts of formats via libraries like serde and
serde_json. I'm depending on the following versions in my Cargo.toml
manifest.
[dependencies]
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
With that, we can define a data schema as regular Rust structs/enums
and simply annotate them with serde's Deserialize derive macro to
automatically implement deserialization for it. I recommend using the
deny_unknown_fields attribute to make sure unknown JSON fields aren't
silently ignored. These attributes are documented separately on the
serde.rs website (not on docs.rs).
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Data {
#[serde(rename = "statusCode")]
status_code: Option,
error: Option,
message: Option,
disruptions: Option>,
lines: Option>,
#[serde(rename = "lastUpdatedDate")]
last_updated_date: Option,
}
One can then trivially deserialize a JSON file into a Data struct
with functions like serde_json::from_reader().
// Open a file for reading.
let file = File::open(path)?;
// Add a layer of buffering for performance.
let reader = BufReader::new(file);
// Deserialize the JSON contents into a Data.
let data: Data = serde_json::from_reader(reader)?;
To give more details about the specific data schema I'm importing,
each Disruption contains informative fields, as well as a list of
time periods during which it applies. For example, there may be
construction work on a line every evening for a month, so there would
be an ApplicationPeriod for each of these evenings.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Disruption {
id: String,
#[serde(rename = "applicationPeriods")]
application_periods: Vec,
#[serde(rename = "lastUpdate")]
last_update: String,
cause: String,
severity: String,
tags: Option>,
title: String,
message: String,
disruption_id: Option,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ApplicationPeriod {
begin: String,
end: String,
}
The data also contains an index by Lines, listing all the objects
(e.g. stations) impacted by disruptions on each metro line.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Line {
id: String,
name: String,
#[serde(rename = "shortName")]
short_name: String,
mode: String,
#[serde(rename = "networkId")]
network_id: String,
#[serde(rename = "impactedObjects")]
impacted_objects: Vec,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ImpactedObject {
#[serde(rename = "type")]
typ: String,
id: String,
name: String,
#[serde(rename = "disruptionIds")]
disruption_ids: Vec,
}
Lastly, I wanted to estimate how much space these objects take in
memory, in order to benchmark the improvements obtained by interning
methods. Rust's std::mem::size_of() function returns the "stack" size
of an object, but that's not sufficient as it ignores any data
indirectly allocated on the heap (such as via a Vec collection).
Therefore, I defined a new trait and implemented it for the needed
types.
trait EstimateSize: Sized {
/// Returns the number of bytes indirectly allocated on the heap by this object.
fn allocated_bytes(&self) -> usize;
/// Returns the total number of bytes that this object uses.
fn estimated_bytes(&self) -> usize {
std::mem::size_of::() + self.allocated_bytes()
}
}
impl EstimateSize for i32 {
fn allocated_bytes(&self) -> usize {
0 // Nothing allocated on the heap.
}
}
impl EstimateSize for String {
fn allocated_bytes(&self) -> usize {
self.len() // Each item is one byte long. Ignores the string capacity.
}
}
impl EstimateSize for Vec {
fn allocated_bytes(&self) -> usize {
// Recursively sum each item's total size.
self.iter().map(|x| x.estimated_bytes()).sum()
}
}
For compound types (structs), the implementation visits all the
fields. In principle, this could automatically be implemented by a
derive macro (like serde's Deserialize), but creating a new macro
seemed overkill given the scale of my experiment.
impl EstimateSize for Data {
fn allocated_bytes(&self) -> usize {
self.status_code.allocated_bytes()
+ self.error.allocated_bytes()
+ self.message.allocated_bytes()
+ self.disruptions.allocated_bytes()
+ self.lines.allocated_bytes()
+ self.last_updated_date.allocated_bytes()
}
}
With that, reading all the files from May 2024 gave the following
result: expanding the 1.1 GB of JSON files into in-memory structs
increased the size by 35% (commit d961e6e). Not in the right
direction... let's start optimizing!
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Interning
In this section, I'll present the basics of the interning pattern,
and how to apply it to various types.
Strings (47%)
The first use case that comes to mind in terms of interning is
strings, as evidenced by the top Rust interning libraries. We're not
going to use any of these packages, as my goal was to learn more
about the inner details of interning.
I stumbled upon a blog post by matklad from 2020 titled Fast and
Simple Rust Interner, and my first iteration is inspired by this
design. The main difference is that I wrapped strings into an Rc
(reference-counted wrapper) to avoid duplicating them in memory. If
the interner is intended to be used from multiple threads, I'd use an
Arc instead, but I'm keeping things simple for this experiment.
So what is an interner? It's essentially a table of strings,
consisting of a vector of strings paired with a hash map that allows
looking up if a string is already in the database, and if so at which
index in the vector.
use std::collections::HashMap;
use std::rc::Rc;
#[derive(Default)]
struct StringInternerImpl {
vec: Vec>,
map: HashMap, usize>,
}
impl StringInternerImpl {
fn intern(&mut self, value: String) -> usize {
if let Some(&id) = self.map.get(&value) {
return id;
}
let id = self.vec.len();
let rc: Rc = Rc::new(value);
self.vec.push(Rc::clone(&rc));
self.map.insert(rc, id);
id
}
fn lookup(&self, id: usize) -> Rc {
Rc::clone(&self.vec[id])
}
}
An interned string is then an index in the interner table. The main
advantage of this setup is to reduce the amount of memory used by the
program, because an integer index is usually smaller than the full
string. This is especially effective when there are many repeated
strings.
Memory layout of strings without and with interning Memory layout of
strings without and with interning.
There are several possible designs to represent an interned string
object: as a first iteration I've chosen to pair the index with a
reference to the interner that contains it.
struct IString<'a> {
interner: &'a StringInterner,
id: usize,
}
impl<'a> IString<'a> {
fn from(interner: &'a StringInterner, value: String) -> Self {
let id = interner.intern(value);
Self { interner, id }
}
fn lookup(&self) -> Rc {
self.interner.lookup(self.id)
}
}
At this point, you might have noticed that I've declared
StringInterner and StringInternerImpl types. What's the difference?
The answer is that once an IString captures an interner handle &
StringInterner, the underlying StringInterner cannot be mutated
anymore via an intern() function taking a &mut self parameter, as it
would break Rust's aliasing rules: there cannot be both a &
StringInterner and a &mut StringInterner pointing to the same thing
at the same time. This is quite unfortunate, as it prevents interning
more than one string!
The way to resolve this conflict is to use interior mutability via
the RefCell type. By defining a StringInterner as a RefCell
, we can intern more values without needing a &
mut self reference to the interner.
#[derive(Default)]
struct StringInterner {
inner: RefCell,
}
impl StringInterner {
// This function takes an immutable reference!
fn intern(&self, value: String) -> usize {
// The borrow_mut() method performs runtime checks and releases a
// mutable reference if it's safe to do so (or panics).
self.inner.borrow_mut().intern(value)
}
fn lookup(&self, id: usize) -> Rc {
self.inner.borrow().lookup(id)
}
}
With this setup, we can for example overload the comparison operator
== to directly compare interned strings with regular strings.
impl PartialEq for IString<'_> {
fn eq(&self, other: &String) -> bool {
self.lookup().deref() == other
}
}
Lastly, I've defined new structs for the data schema using the
interned string type in place of all strings. This means adding a
lifetime 'a everywhere, which isn't quite ergonomic, but we'll
revisit this pattern later.
struct Disruption<'a> {
id: IString<'a>,
application_periods: Vec>,
last_update: IString<'a>,
cause: IString<'a>,
severity: IString<'a>,
tags: Option>>,
title: IString<'a>,
message: IString<'a>,
disruption_id: Option>,
}
struct ApplicationPeriod<'a> {
begin: IString<'a>,
end: IString<'a>,
}
I've also defined functions to convert data from the original structs
to their interned counterparts, as well as comparison functions to
validate that the interned data is semantically equivalent to the
original (and confirm that my benchmarks are not cheating).
impl<'a> ApplicationPeriod<'a> {
fn from(interner: &'a StringInterner, source: source::ApplicationPeriod) -> Self {
Self {
begin: IString::from(interner, source.begin),
end: IString::from(interner, source.end),
}
}
}
impl PartialEq for ApplicationPeriod<'_> {
fn eq(&self, other: &source::ApplicationPeriod) -> bool {
self.begin == other.begin && self.end == other.end
}
}
With that, we can already see the effectiveness of interning: each
string appeared on average 425 times in the input data, the in-memory
data is three times smaller than the baseline, and twice smaller than
the original JSON files (commit 5297faa). We're still quite far from
the headline of this post though!
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 529308335 bytes (relative size = 46.55%)
- [0.84%] String interner: 56374 objects | 4433343 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
Arbitrary types (7.6%)
The next step was to realize that strings aren't the only objects
that repeat a lot in the input data. For example, a Disruption may
last maybe an hour if it's unexpected, or something like a month if
it's planned maintenance work. Given that the input data is a time
series sampled every 2 minutes, it's expected that a given disruption
will show up many times.
Fortunately, the interning technique isn't unique to strings: any
data that can be put into a vector and a hash map should work as
well. So we can use generics to make it work for arbitrary types that
implement Eq and Hash (to work with a hash map).
// Type alias for convenience.
type IString<'a> = Interned<'a, String>;
struct Interned<'a, T> {
interner: &'a Interner,
id: usize,
}
impl<'a, T: Eq + Hash> Interned<'a, T> {
fn from(interner: &'a Interner, value: T) -> Self {
let id = interner.intern(value);
Self { interner, id }
}
fn lookup(&self) -> Rc {
self.interner.lookup(self.id)
}
}
As a new requirement, we also need to implement the PartialEq, Eq and
Hash traits on Interned<_>, so that it can be recursively used in
structs that are themselves interned. A naive implementation is to
lookup the actual data and compare or hash it, but we'll revisit that
in a moment.
use std::hash::{Hash, Hasher};
impl PartialEq for Interned<'_, T> {
fn eq(&self, other: &Self) -> bool {
self.lookup().deref() == other.lookup().deref()
}
}
impl Eq for Interned<'_, T> {}
impl Hash for Interned<'_, T> {
fn hash(&self, state: &mut H)
where
H: Hasher,
{
self.lookup().deref().hash(state)
}
}
On the schema side, we'll now have a collection of interners: each
type gets its own interner.
#[derive(Default)]
struct Interners<'a> {
string: Interner,
disruption: Interner>,
line: Interner>,
application_period: Interner>,
impacted_object: Interner>,
}
The data structs in the schema can now contain interned versions of
other structs, such as Interned<'a, ApplicationPeriod<'a>>, and
simply derive the comparison and hashing traits.
#[derive(Debug, Hash, PartialEq, Eq)]
struct Disruption<'a> {
id: IString<'a>,
application_periods: Vec>>,
last_update: IString<'a>,
cause: IString<'a>,
severity: IString<'a>,
tags: Option>>,
title: IString<'a>,
message: IString<'a>,
disruption_id: Option>,
}
Conversion from the original data types now uses a reference to the
whole collection of Interners.
impl<'a> Disruption<'a> {
fn from(interners: &'a Interners<'a>, source: source::Disruption) -> Self {
Self {
id: Interned::from(&interners.string, source.id),
application_periods: source
.application_periods
.into_iter()
.map(|x| {
Interned::from(
&interners.application_period,
ApplicationPeriod::from(interners, x),
)
})
.collect(),
...
}
}
}
With this generalization, the size improvement starts to be
substantial, about 6 times smaller than the previous step and 12
times smaller than the baseline input files (commit f532ef9).
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 86349519 bytes (relative size = 7.59%)
[67.50%] Interners: 58288479 bytes
- [5.13%] String interner: 56374 objects | 4433343 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
- [1.81%] Disruption interner: 7550 objects | 1565896 bytes (207.40 bytes/object) | 625760 references (82.88 refs/object)
- [0.28%] ApplicationPeriod interner: 5883 objects | 245688 bytes (41.76 bytes/object) | 631593 references (107.36 refs/object)
- [53.61%] Line interner: 97090 objects | 46289464 bytes (476.77 bytes/object) | 930026 references (9.58 refs/object)
- [6.66%] ImpactedObject interner: 56110 objects | 5754088 bytes (102.55 bytes/object) | 3183332 references (56.73 refs/object)
Dropping the reference (2.8%)
One thing you may have noticed is how the reference to an Interner
proliferates well beyond the Interned struct. It forces us (1) to add
a lifetime 'a everywhere and (2) to use interior mutability which
causes the Interner/InternerImpl split.
struct Interned<'a, T> {
interner: &'a Interner,
id: usize,
}
Crucially, it also means inflated memory usage due to a lot of
duplication: a struct like Disruption<'a> contains at least 7
references to the same string interner! So what if we just got rid of
it?
In the current design, the Interned type is intrusive (as it's aware
of the surrounding Interner). We can instead externalize the
interner, and let the caller provide a reference to the interner when
needed.
use std::marker::PhantomData;
struct Interned {
id: usize,
// Marker to indicate that an interned object behaves like a function that
// returns a T (via the lookup method).
_phantom: PhantomData T>,
}
impl Interned {
// The interner reference is now provided by the caller.
fn from(interner: &Interner, value: T) -> Self {
interner.intern(value)
}
// Same here.
fn lookup(&self, interner: &Interner) -> Rc {
interner.lookup(self.id)
}
}
One difficulty with this simplified design is how to implement
comparison and hashing methods on Interned. Indeed, these
operators have a fixed API given by traits such as PartialEq, so an
interner reference cannot be provided as an additional value to the
eq() function for example.
To solve this issue, we can remark that an interned index fully
represents the underlying object (within the realm of an interner):
two values will be interned to the same index if and only if they are
equal. So rather than doing a deep (recursive) comparison, we can
simply compare the indices, i.e. derive their implementations for
Interned. Likewise for hashing.
#[derive(Debug, Hash, PartialEq, Eq)]
struct Interned { /* ... */ }
However, the difficulty remains when we want to compare an Interned
with a T. In that case, we really need to look up the underlying
value and perform a deep comparison. For that purpose, I ended up
defining a new EqWith trait that allows passing the interner as a
helper object for the comparison.
trait EqWith {
fn eq_with(&self, other: &Rhs, helper: &Helper) -> bool;
}
impl EqWith> for Interned {
fn eq_with(&self, other: &T, interner: &Interner) -> bool {
self.lookup(interner).deref() == other
}
}
We can then compare structs from the source and optimized schemas.
#[derive(Debug, Hash, PartialEq, Eq)]
struct ApplicationPeriod {
begin: IString,
end: IString,
}
impl EqWith for ApplicationPeriod {
fn eq_with(&self, other: &source::ApplicationPeriod, interners: &Interners) -> bool {
self.begin.eq_with(&other.begin, &interners.string)
&& self.end.eq_with(&other.end, &interners.string)
}
}
With that, we've halved the size of an Interned, and therefore
almost halved the total in-memory size (commit 59fae78).
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 49829943 bytes (relative size = 4.38%)
[68.66%] Interners: 34215191 bytes
- [8.90%] String interner: 56374 objects | 4433335 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
- [2.17%] Disruption interner: 7550 objects | 1081928 bytes (143.30 bytes/object) | 625760 references (82.88 refs/object)
- [0.30%] ApplicationPeriod interner: 5883 objects | 151552 bytes (25.76 bytes/object) | 631593 references (107.36 refs/object)
- [49.71%] Line interner: 97090 objects | 24768600 bytes (255.11 bytes/object) | 930026 references (9.58 refs/object)
- [7.59%] ImpactedObject interner: 56110 objects | 3779776 bytes (67.36 bytes/object) | 3183332 references (56.73 refs/object)
Can we half it again?
Yes! The original blog post by matklad was using a u32 index, rather
than usize. This is indeed a fairly reasonable choice for objects
that are supposed to be referenced multiple times. In my case, the
dataset didn't contain any type with more than a million distinct
objects, so there was enough margin.
struct Interned {
id: u32, // Now a 32-bit index.
_phantom: PhantomData T>,
}
impl Interner {
fn intern(&mut self, value: T) -> u32 {
if let Some(&id) = self.map.get(&value) {
return id;
}
// Runtime check that the identifier doesn't exceed a u32.
let id = self.vec.len();
assert!(id <= u32::MAX as usize);
let id = id as u32;
let rc: Rc = Rc::new(value);
self.vec.push(Rc::clone(&rc));
self.map.insert(rc, id);
id
}
fn lookup(&self, id: u32) -> Rc {
Rc::clone(&self.vec[id as usize])
}
}
We're now down below 3% of the original data set size (commit 84d79e7
). Steady progress!
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 31391391 bytes (relative size = 2.76%)
[72.41%] Interners: 22730967 bytes
- [14.12%] String interner: 56374 objects | 4433335 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
- [2.48%] Disruption interner: 7550 objects | 779548 bytes (103.25 bytes/object) | 625760 references (82.88 refs/object)
- [0.33%] ApplicationPeriod interner: 5883 objects | 104488 bytes (17.76 bytes/object) | 631593 references (107.36 refs/object)
- [45.86%] Line interner: 97090 objects | 14396532 bytes (148.28 bytes/object) | 930026 references (9.58 refs/object)
- [9.61%] ImpactedObject interner: 56110 objects | 3017064 bytes (53.77 bytes/object) | 3183332 references (56.73 refs/object)
Tuning the schema
Using general interning techniques wasn't the end of the journey.
Indeed, we can leverage business knowledge about the data to optimize
it even more.
Sorting sets (1.5%)
One common pattern in this data was a field containing a set of
objects (themselves interned). For example, a Line contains a set of
impacted objects stored as a Vec>, and each
ImpactedObject contains a set of disruption IDs as a Vec.
struct Line {
id: IString,
name: IString,
short_name: IString,
mode: IString,
network_id: IString,
impacted_objects: Vec>,
}
struct ImpactedObject {
typ: IString,
id: IString,
name: IString,
disruption_ids: Vec,
}
What's interesting is that these sets don't have a particular order,
semantically speaking: we only care about which objects are impacted
on a given metro line, not whether one impacted object is "before"
another (whatever that means). However, in Rust the Vec collection
type is semantically ordered!
This means that two ImpactedObjects with the same typ, id and name
fields but disruption_ids equal to [123, 42, 73] in one case and [73,
123, 42] in the other would be considered different in terms of
hashing and equality, even though they are semantically the same.
As it turns out, the API was returning such sets in arbitrary order
from one call to the next (which I guess makes sense if they
internally represent them using hash tables or hash sets). So one
object containing a set of NNN items could be represented as up to N!
N!N! JSON representations appearing distinct from the perspective of
the interner (number of permutations of NNN items).
Unfortunately, the problem compounds: a Line contains a set of
ImpactedObjects which themselves contain sets of IString. Consider
the following example: each of the two ImpactedObjects has 3!=63! = 6
3!=6 possible representations and there are 2!=22! = 22!=2 possible
orderings of these two objects, so this Line has up to 6[?]6[?]2=726 \
cdot 6 \cdot 2 = 726[?]6[?]2=72 representations. And that's a fairly
simple example, in reality the sets could be longer than 3
disruptions. In practice, the Interner contained the most
number of objects (97090), totaling 14 MB which was 45% of the
optimized bytes.
Line {
impacted_objects: vec![
ImpactedObject {
disruption_ids: vec![1, 2, 3], ...
},
ImpactedObject {
disruption_ids: vec![4, 5, 6], ...
},
],
...
}
// Same object serialized differently.
Line {
impacted_objects: vec![
ImpactedObject {
disruption_ids: vec![6, 4, 5], ...
},
ImpactedObject {
disruption_ids: vec![2, 1, 3], ...
},
],
...
}
To mitigate this problem, we can canonicalize such sets, the easiest
way being to sort them. However, adding ordering operators (via the
PartialOrd and Ord traits) for all the structs in the schema would be
annoying. But that's not required: we only need to order sets of
Interned, and we can do that by simply ordering the underlying
indices! Indeed, all we need is a canonical order, we don't care if
this order reflects the semantics of the objects.
Unfortunately, we cannot derive PartialOrd on Interned if the
underlying T doesn't itself implement it. This is a known and
long-standing limitation of derive (more than 10 years old!).
use std::marker::PhantomData;
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Interned {
id: u32,
_phantom: PhantomData T>,
}
struct MyArbitraryType;
fn foo(set: &mut [Interned]) {
// error[E0277]: the trait bound `MyArbitraryType: Ord` is not satisfied
set.sort_unstable();
}
error[E0277]: the trait bound `MyArbitraryType: Ord` is not satisfied
--> src/lib.rs:13:9
|
13 | set.sort_unstable();
| ^^^^^^^^^^^^^ the trait `Ord` is not implemented for `MyArbitraryType`
|
= help: the trait `Ord` is implemented for `Interned`
note: required for `Interned` to implement `Ord`
--> src/lib.rs:3:37
|
3 | #[derive(PartialEq, Eq, PartialOrd, Ord)]
| ^^^ unsatisfied trait bound introduced in this `derive` macro
note: required by a bound in `core::slice::::sort_unstable`
--> /playground/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/slice/mod.rs:2932:12
|
2930 | pub fn sort_unstable(&mut self)
| ------------- required by a bound in this associated function
2931 | where
2932 | T: Ord,
| ^^^ required by this bound in `core::slice::::sort_unstable`
= note: this error originates in the derive macro `Ord` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `MyArbitraryType` with `#[derive(Ord)]`
|
9 + #[derive(Ord)]
10 | struct MyArbitraryType;
|
So we have to implement the comparison traits manually on Interned
, which isn't that bad. Note that if we implement PartialEq
manually, we can derive(Hash) it but a Clippy lint will warn against
that.
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
impl PartialEq for Interned {
fn eq(&self, other: &Self) -> bool {
self.id.eq(&other.id)
}
}
impl Eq for Interned {}
impl PartialOrd for Interned {
fn partial_cmp(&self, other: &Self) -> Option {
Some(self.cmp(other))
}
}
impl Ord for Interned {
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
impl Hash for Interned {
fn hash(&self, state: &mut H)
where
H: Hasher,
{
self.id.hash(state);
}
}
I then chose to create an InternedSet abstraction, that will sort
the items in canonical order. Note the use of the sort_unstable()
function, which is more efficient than the generic sort(). Also note
that we store the set as a boxed slice Box<[_]> instead of a Vec<_>,
which is more compact for immutable sequences as it doesn't require
storing a capacity field to potentially grow the vector.
#[derive(Debug, Hash, PartialEq, Eq)]
struct InternedSet {
set: Box<[Interned]>,
}
impl InternedSet {
fn new(set: impl IntoIterator>) -> Self {
let mut set: Box<[_]> = set.into_iter().collect();
set.sort_unstable();
Self { set }
}
}
We can then integrate it into the schema as follows.
struct ImpactedObject {
typ: IString,
id: IString,
name: IString,
disruption_ids: InternedSet,
}
impl ImpactedObject {
fn from(interners: &mut Interners, source: source::ImpactedObject) -> Self {
Self {
typ: Interned::from(&mut interners.string, source.typ),
id: Interned::from(&mut interners.string, source.id),
name: Interned::from(&mut interners.string, source.name),
disruption_ids: InternedSet::new(
source
.disruption_ids
.into_iter()
.map(|x| Interned::from(&mut interners.string, x)),
),
}
}
}
This change divided the number of different Line objects by 14, and
the total optimized size by two once again (commit bdb00b5).
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 16578863 bytes (relative size = 1.46%)
[50.70%] Interners: 8405895 bytes
- [26.74%] String interner: 56374 objects | 4433335 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
- [3.97%] Disruption interner: 7550 objects | 658748 bytes (87.25 bytes/object) | 625760 references (82.88 refs/object)
- [0.63%] ApplicationPeriod interner: 5883 objects | 104488 bytes (17.76 bytes/object) | 631593 references (107.36 refs/object)
- [4.06%] Line interner: 6880 objects | 672568 bytes (97.76 bytes/object) | 930026 references (135.18 refs/object)
- [15.30%] ImpactedObject interner: 55373 objects | 2536756 bytes (45.81 bytes/object) | 3183332 references (57.49 refs/object)
Using enums (1.4%)
At this point, the Data root structs used half of the optimized size.
You might have noticed that it contained only optional fields.
struct Data {
status_code: Option,
error: Option,
message: Option,
disruptions: Option>,
lines: Option>,
last_updated_date: Option,
}
However, in practice, the fields that are set come together: either
the object contains an error, with status_code, error and message
fields, or it contains useful data with disruptions, lines and
last_updated_date fields. So a better representation is to use an
enumeration with two variants.
enum Data {
Success {
disruptions: InternedSet,
lines: InternedSet,
last_updated_date: IString,
},
Error {
status_code: i32,
error: IString,
message: IString,
},
}
This separation brings two benefits: the schema is more sound
semantically and takes less space in memory. Indeed, an Interned
uses 4 bytes (a u32 index) but an Option> uses 8 bytes: 1
bit for the option state and the rest to align to a multiple of 4
bytes.
The improvement was more modest this time (commit c8ac261).
Parsed 1137178883 bytes from 30466 files (+ 21 failed files)
Expanded to 1531039733 bytes in memory (relative size = 134.63%)
Optimized to 15847679 bytes (relative size = 1.39%)
[53.04%] Interners: 8405895 bytes
- [27.97%] String interner: 56374 objects | 4433335 bytes (78.64 bytes/object) | 23964083 references (425.09 refs/object)
- [4.16%] Disruption interner: 7550 objects | 658748 bytes (87.25 bytes/object) | 625760 references (82.88 refs/object)
- [0.66%] ApplicationPeriod interner: 5883 objects | 104488 bytes (17.76 bytes/object) | 631593 references (107.36 refs/object)
- [4.24%] Line interner: 6880 objects | 672568 bytes (97.76 bytes/object) | 930026 references (135.18 refs/object)
- [16.01%] ImpactedObject interner: 55373 objects | 2536756 bytes (45.81 bytes/object) | 3183332 references (57.49 refs/object)
Splitting structs (0.82%)
Another thing you may have noticed about the ImpactedObject example
is that it contains both fields that are constant (type, identifier,
name) and fields that change over time (list of disruptions). This
means that each time a disruption is added or removed from the list,
a new ImpactedObject is created and added to the interner, even if
the "header" fields that define the object haven't changed. A more
optimized approach is to extract the fixed fields into a separate
object, and to add a new interner for it.
struct ImpactedObject {
// Fixed part of an object.
object: Interned