From eccb09e868d9981852a153dfaac8aad5ba9fc875 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Wed, 25 Mar 2026 10:23:22 +0000 Subject: [PATCH 01/18] feat: Redis Triemap blogpost --- src/posts/2026-04-XX-redis-triemap.md | 271 ++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/posts/2026-04-XX-redis-triemap.md diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md new file mode 100644 index 0000000000..cafbf98cb0 --- /dev/null +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -0,0 +1,271 @@ +--- + +--- + +Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has been working with them on this project. They've rewritten some of their code to rust already, but this kind of work requires good strategy for both choosing candidates for rewrites and for matching the complex behavior and performance characteristics of the original C code. + +## Choosing a Candidate & Real World Mess + +TODO: Ideal module graph + +Module graph decomposition lets us pick at leaf modules that have no dependencies (that haven't yet been ported) and pick them as candidates for easy rewrites. + +This lets us rewrite one module at a time and export a C FFI for the C code that will depend on it, and depend on the already rewritten stuff as rust modules. + +But real world code is messier! + +TODO: Real-world module graph + +The module dependency of a real-world codebase makes it near impossible to untangle. We run out of viable leaf nodes very quickly, if we even start with them. + +To rewrite something in situ we will need to hook into the C FFI for exporting **and** importing functionality. + +## Case Study: Triemap + +A triemap is a data structure that has a similar API as a hash map / btree map but is optimized for key prefix compression and the ability to do key searches. + + + +We can imagine how to do this easily in Rust, we just need to store a data structure in the form: + +```rust +struct TrieMapNode { + label: Vec, + children: Vec, + data: Option, +} +``` + +Very simple stuff! We can cascade down nodes from the root to complete the labels of leaves, the data field is optional as labels can branch without having an appropriate piece of data at the branch point. A map with only "Scarborough" and "Scaffolding" in it doesn't have data for "Sca". + +Let's have a look at the C implementation: + +```c +#pragma pack(1) +typedef struct { + uint16_t labelLen; + uint16_t numChildren : 9; + uint8_t flags : 7; + void *data; + char label[]; + //... here come the first letters of each child + //... now come the children pointers +} TrieMapNode; + +#pragma pack() +``` + +The core property of this type is that the fields, label, and the array of pointers to children take up a **single heap allocation**. This is really important for cache locality and minimizing pointer dereferences, but it also means the size of the type and some of the offsets of the fields of that type are not known at compile time. + +TODO: Diagram of the C memory layout, or pointing to the video. + +This is a complex type, and translating it to Rust is difficult. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. + +## Porting Strategy: Establish a Baseline + +The first component of our strategy was establishing a baseline implementation of the `TrieMap`, exposing the public API we want to exist at the end of the project and building out an extensive benchmark + test suite. + +```rust +#[repr (C)] +struct Node { + // LowMemoryThinVec uses u16 for capacity, not usize. + // This is still separate heap allocated data though. + label: LowMemoryThinVec, + children: LowMemoryThinVec>, + data: Option, +} + +#[repr (C)] +struct ChildRef { + first_byte: u8, + node: Node, +} +``` + +This implementation makes no attempt to match the performance characteristics of the C original. We have extra heap allocations for labels and children. + +What we get from this baseline is code that works correctly, knowledge of performance characteristics, and a team that has a deeper understanding of the original implementation. + +This working, tested, naive version and refresh of what the original was doing lowers the cognitive load when it comes to the team implementing a later version. + +This first step in the porting strategy left us with 1. Total test coverage of what we were working on and 2. Knowledge of how subpar the performance of this naive TrieMap implementation. + +TODO: Diagram of performance, violin chart. + +Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All of the Vecs we used also meant the memory usage of the naive implementation was double that of the original. + +## Porting Strategy: Iterating for Performance + +Having learnt as much as we could from the naive implementation, we can now start working to match the original. + +We established that the core characteristic of the original TrieMap implementation is how each node occupies _a single heap allocation_. The label, array of pointers to children, and field data occupy the same contiguous space in memory. + +We want to get as close to this original implementation as possible, but if we can make it simpler to maintain then we should. To this end, we decided to abandon the `pack` pragma to avoid dealing with unaligned accesses. CPUs are are optimized for aligned access, so this should mean a negligible trade-off in memory footprint in exchange for speed. + +We want our implementation to have the following properties: + +1. Each node takes up a single heap allocation. +2. The layout is padded, unlike the packed original. + +We designed out layout to be as follows: + +TODO: Show the Rust Layout + +TODO: Explain differences in layout + +### DIY Dynamically Sized Types + +We want nodes to be a Dynamically Sized Type, like `&str` or `&[u8]`. Something whose size is not known at compile-time. + +Our ideal rust implementation would look something like this: + +```rust +#[repr(C)] +struct IdealNode { + label_len: u16, + n_children: u8, + label: [u8; self.label_len], + children_first_bytes: [u8; self.n_children], + children: [*Node; self.n_children], + data: Option +} +``` + +but we cannot express this in rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. + +```rust +// Stack +pub(crate) struct Node { + ptr: NonNull, + _phantom: PhantomData, +} + +// Heap +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub(super) struct NodeHeader { + pub label_len: u16, + pub n_children: u8, + // ...where are the other fields? +} +``` + +Note that in `NodeHeader` we can't express everything we want to in the type system. Instead, we avoid defining them directly and make sure to manually allocate them ourselves. + +### Allocating a Dynamically Sized Type + +The `alloc` function has the signature: + +```rust +pub unsafe fn alloc(layout: Layout) -> *mut u8 +``` + +Rather than C's `malloc` which takes a length in bytes, `alloc` takes a `Layout` type that represents the size and alignment of an allocation. + +`Layout` exposes a fairly simple API, and we can define a layout to suit our complex needs at runtime. + +1. `Layout::new` produces a layout for a sized type. +2. `Layout::array` produces a layout for an array of a sized type, with a runtime-or-const `n` elements. +2. `Layout::extend` allows us to compose layouts together. + +This composable API of `Layout` means it's fairly easy to build the `Layout` we need for our custom Dynamically Sized Type. + +## Porting Strategy: Managing Necessary Unsafe + +Once we have our `Layout`, we can call `alloc` to get our pointer. This memory is uninitialized, but we can initialize it manually. Managing this memory manually only gets gnarly once we want to split or merge nodes.s + +In the final codebase we were finally left with **128** unsafe blocks and **21** unsafe methods. + +Managing this unsafe code requires care and understanding for how developers will interact with it. Preferably a developer won't interact with it at all unless they're a maintainer. + +On top of all this, the client is moving to rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. + +To deal with these constraints, we have a core axiom of **no `unsafe` in the public API**, on top of trying to rely on tooling as much as possible. + +### Tooling & Patterns + +1. Miri + +Miri runs your rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. + +This only works on Rust code, so we can't apply this in other parts of Rust Redis where we cross FFI boundaries into C libraries, but we can use it here and anywhere else where all dependencies are rust. + +2. Debug assertions + +Debug assertions are checks that only run in debug builds, release builds do not pay the performance cost of them. Debug assertions let us check the invariants we want to build our API around at runtime and give good errors when those assertions fail. + +Heavy use of debug assertions ties nicely with our use of Miri, whose errors can be overly esoteric or academic. Rust assertions are much easier to contextualize. + +3. Clippy Lints + +Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) we're using clippy lints to enforce two soft invariants: + +3.1: `undocumented_unsafe_blocks`: If an unsafe block does not have comments surrounding it, there will be a warning. + +3.2: `multiple_unsafe_ops_per_block`: If an unsafe block contains more than one unsafe operation, there will be a warning. + +This pushes developers in the direction of properly documenting the unsafe code they do right, operation by operation, to assure all invariants are documented. + +### Safety Comments + +Following more of Wrenn's guidance, we're also making sure that each safety comment mentions invariants as a list for easy comparison. + +Enumerated safety comments differ between call sites and declaration sites. Declaration sites need to show the assumptions made (invariants) about inputs and context. Call sites need to explain how those invariants are met, one by one. + +```rust +// SAFETY: +// - The source range is all contained within a single allocation. +// - The destination range is all contained within a single allocation. +// - We have exclusive access to the destination buffer, since it was allocated earlier in this function. +// - No one else is mutating the source buffer, since this function owns it. +// - Both source and destination pointers are well aligned, see [PtrMetadata::children_ptr] +// - The two buffers don't overlap. The destination buffer was freshly allocated earlier in this function. +unsafe { + child_ptr.children().ptr().copy_from_nonoverlapping( + old_ptr.children().ptr(), + child_header.n_children as usize, + ) +}; +``` + +Note how long this safety comment is, with 128 unsafe blocks in the codebase this could become an overwhelming amount of information very quickly. This is where the following component of our unsafe management strategy comes in. + +### Hard to Misuse Abstractions + +We build a hierarchy of abstractions to make sure that the further away from the raw allocation APIs we are, the fewer mistakes we can make. + +To do this, we want to make sure that there are as few invariants as possible for a maintainer to uphold at any point with few-to-zero ways to unknowingly break those invariants. + +We do this by **Layering Abstractions** in a way that gradually reduces the concerns of a maintainer. + +**Bottom**: `alloc`, `Layout`, and pointers. + +This is the information we get from naive use of `alloc`. Invariants need to be manually upheld for all operations at this level. At this level, many different + +**Near-Bottom**: `PtrMetadata` + `PtrWithMetadata` + +At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not tracked by the ), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to happen once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. + +**Mid-Level**: Unsafe methods and functions. + +At this layer we're developing methods and functions that perform unsafe operations but have API surfaces which are much more specialized for the tasks we're using them for. + +**Top**: The Safe API + +Finally, we have the safe API surrounding the data structure that we want end users to actually use. + +## Conclusion + +This implementation is now in production in the Redis Query Engine! + +TODO Details on how the final implementation differed in performance. + +Real-world codebases on the level of complexity of the Redis Query Engine require looking carefully into how complex components can be replaced with alternative implementations that better fit the needs of maintainers and users. + +What we get from a rewrite is more than just a codebase translated to a new language. A rewrite is an opportunity for an engineering team to relearn and re-contextualize the core problems that existing code exists to solve, to learn what's important about the building blocks of the software they're developing. + +A rewrite is also an opportunity to reassess assumptions. In the original implementation we had a packed layout, we questioned that assumption and went with a different direction, as well as making other small changes in the memory layout of the data structure. Challenging these assumptions paid off, as our new implementation is faster and has a negligible increase in memory use. + +We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in unsafe, failing the client on maintainability. We could have come in with a new data structure entirely, failing the client on the performance assumptions made in the rest of their codebase. + +The success of this rewrite in terms of speed, performance, and future maintainability is a consequence of taking the client's needs seriously and keeping an open mind on digesting the problems current code solved. \ No newline at end of file From 781bfeeda99a41acda2aa7a36b6c90a10669f21d Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 30 Mar 2026 14:58:51 +0100 Subject: [PATCH 02/18] feat: Trie map blog post editing pass --- src/posts/2026-04-XX-redis-triemap.md | 78 ++++++++++++++------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index cafbf98cb0..583da0c351 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -4,25 +4,9 @@ Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has been working with them on this project. They've rewritten some of their code to rust already, but this kind of work requires good strategy for both choosing candidates for rewrites and for matching the complex behavior and performance characteristics of the original C code. -## Choosing a Candidate & Real World Mess +## Case Study: Porting the Trie Map -TODO: Ideal module graph - -Module graph decomposition lets us pick at leaf modules that have no dependencies (that haven't yet been ported) and pick them as candidates for easy rewrites. - -This lets us rewrite one module at a time and export a C FFI for the C code that will depend on it, and depend on the already rewritten stuff as rust modules. - -But real world code is messier! - -TODO: Real-world module graph - -The module dependency of a real-world codebase makes it near impossible to untangle. We run out of viable leaf nodes very quickly, if we even start with them. - -To rewrite something in situ we will need to hook into the C FFI for exporting **and** importing functionality. - -## Case Study: Triemap - -A triemap is a data structure that has a similar API as a hash map / btree map but is optimized for key prefix compression and the ability to do key searches. +A Trie Map is a key-value data structure that has a similar API as a Hash Map / Binary Tree Map, but optimized for compressing keys by their shared prefixes. @@ -32,13 +16,15 @@ We can imagine how to do this easily in Rust, we just need to store a data struc struct TrieMapNode { label: Vec, children: Vec, + // The data that the label "maps" to. + // This is optional, because not all nodes have complete keys with associated data. data: Option, } ``` -Very simple stuff! We can cascade down nodes from the root to complete the labels of leaves, the data field is optional as labels can branch without having an appropriate piece of data at the branch point. A map with only "Scarborough" and "Scaffolding" in it doesn't have data for "Sca". +Very simple stuff! We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. -Let's have a look at the C implementation: +Let's take a look at the C implementation: ```c #pragma pack(1) @@ -92,7 +78,7 @@ This first step in the porting strategy left us with 1. Total test coverage of w TODO: Diagram of performance, violin chart. -Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All of the Vecs we used also meant the memory usage of the naive implementation was double that of the original. +Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the `Vec`s we used also meant the memory usage of the naive implementation was double that of the original. ## Porting Strategy: Iterating for Performance @@ -100,7 +86,7 @@ Having learnt as much as we could from the naive implementation, we can now star We established that the core characteristic of the original TrieMap implementation is how each node occupies _a single heap allocation_. The label, array of pointers to children, and field data occupy the same contiguous space in memory. -We want to get as close to this original implementation as possible, but if we can make it simpler to maintain then we should. To this end, we decided to abandon the `pack` pragma to avoid dealing with unaligned accesses. CPUs are are optimized for aligned access, so this should mean a negligible trade-off in memory footprint in exchange for speed. +We want to get as close to this original implementation as possible, but if we can make it simpler to maintain then we should. To this end, we decided to abandon the `pack` pragma to avoid dealing with unaligned accesses. CPUs are optimized for aligned access, so this should mean a negligible trade-off in memory footprint in exchange for speed. We want our implementation to have the following properties: @@ -111,7 +97,9 @@ We designed out layout to be as follows: TODO: Show the Rust Layout -TODO: Explain differences in layout +There are some differences between our Rust layout and the original C layout: + +1. Our optional leaf data sits at the end of the struct, rather than before the label and children. ### DIY Dynamically Sized Types @@ -131,7 +119,7 @@ struct IdealNode { } ``` -but we cannot express this in rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. +But we cannot express this in rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. ```rust // Stack @@ -172,7 +160,7 @@ This composable API of `Layout` means it's fairly easy to build the `Layout` we ## Porting Strategy: Managing Necessary Unsafe -Once we have our `Layout`, we can call `alloc` to get our pointer. This memory is uninitialized, but we can initialize it manually. Managing this memory manually only gets gnarly once we want to split or merge nodes.s +Once we have our `Layout`, we can call `alloc` to get our pointer. This memory is uninitialized, but we can initialize it manually. Managing this memory manually only gets gnarly once we want to split or merge nodes. In the final codebase we were finally left with **128** unsafe blocks and **21** unsafe methods. @@ -182,27 +170,29 @@ On top of all this, the client is moving to rust to minimize issues related to m To deal with these constraints, we have a core axiom of **no `unsafe` in the public API**, on top of trying to rely on tooling as much as possible. -### Tooling & Patterns +### Tooling & Patterns + +Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate this process, otherwise it wouldn't be `unsafe`, but we can do our best to build and maintain certainty. -1. Miri +1. **Miri** Miri runs your rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. This only works on Rust code, so we can't apply this in other parts of Rust Redis where we cross FFI boundaries into C libraries, but we can use it here and anywhere else where all dependencies are rust. -2. Debug assertions +2. **Debug assertions** Debug assertions are checks that only run in debug builds, release builds do not pay the performance cost of them. Debug assertions let us check the invariants we want to build our API around at runtime and give good errors when those assertions fail. Heavy use of debug assertions ties nicely with our use of Miri, whose errors can be overly esoteric or academic. Rust assertions are much easier to contextualize. -3. Clippy Lints +3. **Clippy Lints** Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) we're using clippy lints to enforce two soft invariants: -3.1: `undocumented_unsafe_blocks`: If an unsafe block does not have comments surrounding it, there will be a warning. +- 3.1: `undocumented_unsafe_blocks`: If an unsafe block does not have comments surrounding it, there will be a warning. -3.2: `multiple_unsafe_ops_per_block`: If an unsafe block contains more than one unsafe operation, there will be a warning. +- 3.2: `multiple_unsafe_ops_per_block`: If an unsafe block contains more than one unsafe operation, there will be a warning. This pushes developers in the direction of properly documenting the unsafe code they do right, operation by operation, to assure all invariants are documented. @@ -228,15 +218,15 @@ unsafe { }; ``` -Note how long this safety comment is, with 128 unsafe blocks in the codebase this could become an overwhelming amount of information very quickly. This is where the following component of our unsafe management strategy comes in. +Note how long this safety comment is, with 128 unsafe blocks in the codebase this could become an overwhelming amount of information very quickly. This is where the next component of our unsafe management strategy comes in. -### Hard to Misuse Abstractions +### Abstractions that are Hard to Misuse -We build a hierarchy of abstractions to make sure that the further away from the raw allocation APIs we are, the fewer mistakes we can make. +Unsafe doesn't let users or maintainers do "anything", but it does let them get away with misusing the tools they're given in ways that make software systems unpredictable. -To do this, we want to make sure that there are as few invariants as possible for a maintainer to uphold at any point with few-to-zero ways to unknowingly break those invariants. +To strengthen systems built on unsafe foundations, we start by **Layering Abstractions** in a way that gradually reduces the concerns of a maintainer. -We do this by **Layering Abstractions** in a way that gradually reduces the concerns of a maintainer. +The end goal is to have a outwardly safe API, and a internal implementation that restricts what mistakes a maintainer to specific areas of the code. Core, most-unsafe operations are built upon by still-unsafe abstractions that limit the operations the maintainer can perform, and those abstractions are built upon by more-safe abstractions. **Bottom**: `alloc`, `Layout`, and pointers. @@ -256,9 +246,21 @@ Finally, we have the safe API surrounding the data structure that we want end us ## Conclusion -This implementation is now in production in the Redis Query Engine! +This implementation is now in production in the Redis Query Engine! We can note & measure some key changes in the codebase from this transition. + +|| C | Rust | +|-|-|-| +|Lines of Code|~1k|~1.9k| +|Unsafe expressions|-|128| +|Coverage|82.8%|95.6%| +|Microbenchmarks|❌|✅| +|Performance|(baseline)|25% to 100% improvement in core operations| + +The codebase did double in size, but with a move to a Rust codebase the unsafe operations have been confined to a small fraction of the original C codebase, where unsafe operations could have been happening anywhere. + +Test coverage was greatly improved, the only areas where tests are lacking in the Rust codebase are in -TODO Details on how the final implementation differed in performance. +Performance was also greatly improved, in part from changing the layout from Packed to Unpacked. Real-world codebases on the level of complexity of the Redis Query Engine require looking carefully into how complex components can be replaced with alternative implementations that better fit the needs of maintainers and users. From e7e049caa3ce44ada4c4abb82b0a1ced113178d9 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 30 Mar 2026 15:00:06 +0100 Subject: [PATCH 03/18] incomplete sentence --- src/posts/2026-04-XX-redis-triemap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 583da0c351..bf26ffd0d9 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -258,7 +258,7 @@ This implementation is now in production in the Redis Query Engine! We can note The codebase did double in size, but with a move to a Rust codebase the unsafe operations have been confined to a small fraction of the original C codebase, where unsafe operations could have been happening anywhere. -Test coverage was greatly improved, the only areas where tests are lacking in the Rust codebase are in +Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. Performance was also greatly improved, in part from changing the layout from Packed to Unpacked. From 815c1eab1656ff83083256e86ed983235bccced2 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 30 Mar 2026 15:01:12 +0100 Subject: [PATCH 04/18] wording nitpick --- src/posts/2026-04-XX-redis-triemap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index bf26ffd0d9..777055f4dd 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -256,7 +256,7 @@ This implementation is now in production in the Redis Query Engine! We can note |Microbenchmarks|❌|✅| |Performance|(baseline)|25% to 100% improvement in core operations| -The codebase did double in size, but with a move to a Rust codebase the unsafe operations have been confined to a small fraction of the original C codebase, where unsafe operations could have been happening anywhere. +The codebase did double in size, but with a move to a Rust codebase the unsafe operations have been confined to an area of the code a fraction of the size of the C codebase, where unsafe operations could have been happening anywhere. Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. From 827b8418a436a2aa803de47acf0ed9245852dc9b Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 13 Apr 2026 13:20:05 +0100 Subject: [PATCH 05/18] add diagrams taken from slides --- src/posts/2026-04-XX-redis-triemap.md | 11 ++++++----- .../posts/2026-04-XX-redis-triemap/c-layout.svg | 1 + .../posts/2026-04-XX-redis-triemap/rust-layout.svg | 1 + .../images/posts/2026-04-XX-redis-triemap/trie.svg | 1 + .../posts/2026-04-XX-redis-triemap/violin-plot.svg | 1 + 5 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 static/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg create mode 100644 static/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg create mode 100644 static/assets/images/posts/2026-04-XX-redis-triemap/trie.svg create mode 100644 static/assets/images/posts/2026-04-XX-redis-triemap/violin-plot.svg diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 777055f4dd..88a7ee0fec 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -6,9 +6,9 @@ Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has ## Case Study: Porting the Trie Map -A Trie Map is a key-value data structure that has a similar API as a Hash Map / Binary Tree Map, but optimized for compressing keys by their shared prefixes. +A Trie Map is a key-value data structure that has a similar API as a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. - +![Diagram showing how a set of tuples are represented in a Triemap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) We can imagine how to do this easily in Rust, we just need to store a data structure in the form: @@ -43,7 +43,7 @@ typedef struct { The core property of this type is that the fields, label, and the array of pointers to children take up a **single heap allocation**. This is really important for cache locality and minimizing pointer dereferences, but it also means the size of the type and some of the offsets of the fields of that type are not known at compile time. -TODO: Diagram of the C memory layout, or pointing to the video. +![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) This is a complex type, and translating it to Rust is difficult. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. @@ -76,7 +76,8 @@ This working, tested, naive version and refresh of what the original was doing l This first step in the porting strategy left us with 1. Total test coverage of what we were working on and 2. Knowledge of how subpar the performance of this naive TrieMap implementation. -TODO: Diagram of performance, violin chart. + +![](/assets/images/posts/2026-04-XX-redis-triemap/violin-chart.svg) Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the `Vec`s we used also meant the memory usage of the naive implementation was double that of the original. @@ -95,7 +96,7 @@ We want our implementation to have the following properties: We designed out layout to be as follows: -TODO: Show the Rust Layout +![](/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg) There are some differences between our Rust layout and the original C layout: diff --git a/static/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg b/static/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg new file mode 100644 index 0000000000..ecb80fc571 --- /dev/null +++ b/static/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg b/static/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg new file mode 100644 index 0000000000..6f9f94e5f1 --- /dev/null +++ b/static/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/images/posts/2026-04-XX-redis-triemap/trie.svg b/static/assets/images/posts/2026-04-XX-redis-triemap/trie.svg new file mode 100644 index 0000000000..b91c7671ab --- /dev/null +++ b/static/assets/images/posts/2026-04-XX-redis-triemap/trie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/images/posts/2026-04-XX-redis-triemap/violin-plot.svg b/static/assets/images/posts/2026-04-XX-redis-triemap/violin-plot.svg new file mode 100644 index 0000000000..588579787b --- /dev/null +++ b/static/assets/images/posts/2026-04-XX-redis-triemap/violin-plot.svg @@ -0,0 +1 @@ + \ No newline at end of file From 5e6dfdc0b345725cbb0583cf7be6ded8c5594f7e Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:24:45 +0100 Subject: [PATCH 06/18] Apply suggestions from code review Co-authored-by: Henk Oordt --- src/posts/2026-04-XX-redis-triemap.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 88a7ee0fec..ffb5fb96a7 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -2,7 +2,7 @@ --- -Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has been working with them on this project. They've rewritten some of their code to rust already, but this kind of work requires good strategy for both choosing candidates for rewrites and for matching the complex behavior and performance characteristics of the original C code. +Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has been working with them on this project. They've rewritten some of their code to Rust already, but this kind of work requires good strategy both for choosing rewrite candidates and matching the complex behavior and performance characteristics of the original C code. ## Case Study: Porting the Trie Map @@ -45,7 +45,7 @@ The core property of this type is that the fields, label, and the array of point ![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) -This is a complex type, and translating it to Rust is difficult. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. +This is a complex type, and translating it to Rust is difficult. It cannot even be fully specified in C. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. ## Porting Strategy: Establish a Baseline @@ -70,7 +70,7 @@ struct ChildRef { This implementation makes no attempt to match the performance characteristics of the C original. We have extra heap allocations for labels and children. -What we get from this baseline is code that works correctly, knowledge of performance characteristics, and a team that has a deeper understanding of the original implementation. +What we get from this baseline is code that works correctly, a well thought out external API, knowledge of performance characteristics, and a team that has a deeper understanding of the original implementation. This working, tested, naive version and refresh of what the original was doing lowers the cognitive load when it comes to the team implementing a later version. @@ -106,7 +106,7 @@ There are some differences between our Rust layout and the original C layout: We want nodes to be a Dynamically Sized Type, like `&str` or `&[u8]`. Something whose size is not known at compile-time. -Our ideal rust implementation would look something like this: +Our ideal Rust implementation would look something like this: ```rust #[repr(C)] @@ -120,7 +120,7 @@ struct IdealNode { } ``` -But we cannot express this in rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. +But we cannot express this in Rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. ```rust // Stack @@ -143,7 +143,7 @@ Note that in `NodeHeader` we can't express everything we want to in the type sys ### Allocating a Dynamically Sized Type -The `alloc` function has the signature: +The `std::alloc::alloc` function has the signature: ```rust pub unsafe fn alloc(layout: Layout) -> *mut u8 @@ -167,13 +167,13 @@ In the final codebase we were finally left with **128** unsafe blocks and **21** Managing this unsafe code requires care and understanding for how developers will interact with it. Preferably a developer won't interact with it at all unless they're a maintainer. -On top of all this, the client is moving to rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. +On top of all this, the client is moving to Rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. To deal with these constraints, we have a core axiom of **no `unsafe` in the public API**, on top of trying to rely on tooling as much as possible. ### Tooling & Patterns -Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate this process, otherwise it wouldn't be `unsafe`, but we can do our best to build and maintain certainty. +Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate this process, which is why we have to resort to `unsafe`, but we can do our best to build and maintain certainty. 1. **Miri** @@ -261,7 +261,7 @@ The codebase did double in size, but with a move to a Rust codebase the unsafe o Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. -Performance was also greatly improved, in part from changing the layout from Packed to Unpacked. +Performance was also greatly improved, in part from moving away from the packed layout. Real-world codebases on the level of complexity of the Redis Query Engine require looking carefully into how complex components can be replaced with alternative implementations that better fit the needs of maintainers and users. From 41baf6f209574872fbfd17c62f4663aaf6da24d6 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 13 Apr 2026 14:04:24 +0100 Subject: [PATCH 07/18] Address more feedback. --- src/posts/2026-04-XX-redis-triemap.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index ffb5fb96a7..bf69cfa084 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -98,9 +98,12 @@ We designed out layout to be as follows: ![](/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg) -There are some differences between our Rust layout and the original C layout: +There are a couple of differences between our Rust layout and the original C layout: -1. Our optional leaf data sits at the end of the struct, rather than before the label and children. +1. The data structure is padded, so depending on the number of children there may be unused space between the end of `children_first_bytes` and `children`. +2. Our optional leaf data sits at the end of the struct, rather than before the label and children. + +How are we supposed to implement this in Rust? We do it with designing our own Dynamically Sized Types. ### DIY Dynamically Sized Types @@ -139,7 +142,7 @@ pub(super) struct NodeHeader { } ``` -Note that in `NodeHeader` we can't express everything we want to in the type system. Instead, we avoid defining them directly and make sure to manually allocate them ourselves. +Note that in `NodeHeader`, just like in the C implementation, we can't express everything we want to in the type system. Instead, we avoid defining them directly and make sure to manually allocate them ourselves. ### Allocating a Dynamically Sized Type @@ -155,7 +158,7 @@ Rather than C's `malloc` which takes a length in bytes, `alloc` takes a `Layout` 1. `Layout::new` produces a layout for a sized type. 2. `Layout::array` produces a layout for an array of a sized type, with a runtime-or-const `n` elements. -2. `Layout::extend` allows us to compose layouts together. +3. `Layout::extend` allows us to compose layouts together. This composable API of `Layout` means it's fairly easy to build the `Layout` we need for our custom Dynamically Sized Type. @@ -183,7 +186,7 @@ This only works on Rust code, so we can't apply this in other parts of Rust Redi 2. **Debug assertions** -Debug assertions are checks that only run in debug builds, release builds do not pay the performance cost of them. Debug assertions let us check the invariants we want to build our API around at runtime and give good errors when those assertions fail. +Debug assertions are checks that only run in debug builds, release builds do not pay the performance cost of them. Debug assertions let us both specify and check the invariants we want to build our API around at runtime, and give good errors when those assertions fail. Heavy use of debug assertions ties nicely with our use of Miri, whose errors can be overly esoteric or academic. Rust assertions are much easier to contextualize. From 52b5a4df5d55436d0229f54e396550e39adf4a2c Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 20 Apr 2026 13:11:58 +0100 Subject: [PATCH 08/18] More editing --- src/posts/2026-04-XX-redis-triemap.md | 60 +++++++++++++-------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index bf69cfa084..c083e2694e 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -2,15 +2,17 @@ --- -Redis wants to migrate the Redis Query Engine from C to Rust, and Mainmatter has been working with them on this project. They've rewritten some of their code to Rust already, but this kind of work requires good strategy both for choosing rewrite candidates and matching the complex behavior and performance characteristics of the original C code. +At Mainmatter we've been working to help Redis migrate the Redis Query Engine from C to Rust. They've ported some of their existing query engine code already, but this kind of work requires good strategy in both choosing good rewrite candidates and matching the complex behavior and performance characteristics of the original C code. -## Case Study: Porting the Trie Map +This blog post covers -A Trie Map is a key-value data structure that has a similar API as a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. +## Case Study: Porting the TrieMap -![Diagram showing how a set of tuples are represented in a Triemap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) +A TrieMap is a key-value data structure that has a similar API as a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. -We can imagine how to do this easily in Rust, we just need to store a data structure in the form: +![Diagram showing how a set of tuples are represented in a TrieMap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) + +We can easily imagine how to do this in Rust, we just need to store a data structure in the form: ```rust struct TrieMapNode { @@ -24,7 +26,7 @@ struct TrieMapNode { Very simple stuff! We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. -Let's take a look at the C implementation: +Let's take a look at the preexisting C implementation: ```c #pragma pack(1) @@ -43,9 +45,11 @@ typedef struct { The core property of this type is that the fields, label, and the array of pointers to children take up a **single heap allocation**. This is really important for cache locality and minimizing pointer dereferences, but it also means the size of the type and some of the offsets of the fields of that type are not known at compile time. +Note how this type is not fully definable in C's type system, the two lines of comments here are fields that we can't define in a C struct definition and need to compute how to access at runtime. `labelLen` and `numChildren` let us do these computations. + ![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) -This is a complex type, and translating it to Rust is difficult. It cannot even be fully specified in C. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. +This is a complex type, and translating it to Rust is difficult. As we've said already, it can't even be fully specified in C's type system. Translating it to safe Rust is impossible, but that doesn't mean we can't translate it _safely_. ## Porting Strategy: Establish a Baseline @@ -68,33 +72,27 @@ struct ChildRef { } ``` -This implementation makes no attempt to match the performance characteristics of the C original. We have extra heap allocations for labels and children. - -What we get from this baseline is code that works correctly, a well thought out external API, knowledge of performance characteristics, and a team that has a deeper understanding of the original implementation. - -This working, tested, naive version and refresh of what the original was doing lowers the cognitive load when it comes to the team implementing a later version. +This implementation makes no attempt to match the performance characteristics of the C original, so we have extra heap allocations for labels and children to maintain the simplicity. -This first step in the porting strategy left us with 1. Total test coverage of what we were working on and 2. Knowledge of how subpar the performance of this naive TrieMap implementation. +Building off of this naive implementation, we can design an external API that can be tested and benchmarked. This working, tested, naive version and refresh of what the original was doing lowers the cognitive load when it comes to the team implementing a later version. +We didn't focus on performance, because that wasn't what this initial information-gathering pass was about. But we might as well make the comparisons we can so we know how important the optimization work will be: ![](/assets/images/posts/2026-04-XX-redis-triemap/violin-chart.svg) -Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the `Vec`s we used also meant the memory usage of the naive implementation was double that of the original. +Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the instances of `Vec` we used also meant the memory usage of the naive implementation was double that of the original. + +This first step in the porting strategy left us with 1. Total test coverage of what we were working on 2. Knowledge of how subpar the performance of this naive TrieMap implementation and 3. A team that has a deeper understanding of the original implementation and the decisions behind it. ## Porting Strategy: Iterating for Performance -Having learnt as much as we could from the naive implementation, we can now start working to match the original. +Having learnt as much as we could from the naive implementation, we can now start working to match the original in performance. We established that the core characteristic of the original TrieMap implementation is how each node occupies _a single heap allocation_. The label, array of pointers to children, and field data occupy the same contiguous space in memory. We want to get as close to this original implementation as possible, but if we can make it simpler to maintain then we should. To this end, we decided to abandon the `pack` pragma to avoid dealing with unaligned accesses. CPUs are optimized for aligned access, so this should mean a negligible trade-off in memory footprint in exchange for speed. -We want our implementation to have the following properties: - -1. Each node takes up a single heap allocation. -2. The layout is padded, unlike the packed original. - -We designed out layout to be as follows: +The layout we ended up designing is as follows: ![](/assets/images/posts/2026-04-XX-redis-triemap/rust-layout.svg) @@ -107,7 +105,7 @@ How are we supposed to implement this in Rust? We do it with designing our own D ### DIY Dynamically Sized Types -We want nodes to be a Dynamically Sized Type, like `&str` or `&[u8]`. Something whose size is not known at compile-time. +We want nodes to be a Dynamically Sized Type, like `str` or `[u8]` (note the lack of `&`). Something whose size is not known at compile-time. Our ideal Rust implementation would look something like this: @@ -123,7 +121,7 @@ struct IdealNode { } ``` -But we cannot express this in Rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer, and the heap type `NodeHeader` stores our data as if it were `IdealNode`. +But we cannot express this in Rust's type system. So instead, we split up the implementation into a stack type and a heap type. The stack type `Node` stores the pointer so we can get our `IdealNode`-like API, and the heap type `NodeHeader` stores our dynamically-sized heap-allocated data. ```rust // Stack @@ -180,7 +178,7 @@ Our management of the unsafe code is a mix of good documentation practices for c 1. **Miri** -Miri runs your rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. +Miri runs your rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. This only works on Rust code, so we can't apply this in other parts of Rust Redis where we cross FFI boundaries into C libraries, but we can use it here and anywhere else where all dependencies are rust. @@ -198,7 +196,7 @@ Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) w - 3.2: `multiple_unsafe_ops_per_block`: If an unsafe block contains more than one unsafe operation, there will be a warning. -This pushes developers in the direction of properly documenting the unsafe code they do right, operation by operation, to assure all invariants are documented. +This pushes developers in the direction of properly documenting the unsafe code they do write, operation by operation, to assure all invariants are documented. ### Safety Comments @@ -224,7 +222,7 @@ unsafe { Note how long this safety comment is, with 128 unsafe blocks in the codebase this could become an overwhelming amount of information very quickly. This is where the next component of our unsafe management strategy comes in. -### Abstractions that are Hard to Misuse +### Abstractions That Are Hard to Misuse Unsafe doesn't let users or maintainers do "anything", but it does let them get away with misusing the tools they're given in ways that make software systems unpredictable. @@ -234,7 +232,7 @@ The end goal is to have a outwardly safe API, and a internal implementation that **Bottom**: `alloc`, `Layout`, and pointers. -This is the information we get from naive use of `alloc`. Invariants need to be manually upheld for all operations at this level. At this level, many different +At this level, maintainer error is at its most possible. Invariants need to be manually upheld for all operations at this level. We want to minimize the amount of code that uses these tools directly. **Near-Bottom**: `PtrMetadata` + `PtrWithMetadata` @@ -242,11 +240,11 @@ At this level, we have the metadata needed to construct a pointer (`PtrMetadata` **Mid-Level**: Unsafe methods and functions. -At this layer we're developing methods and functions that perform unsafe operations but have API surfaces which are much more specialized for the tasks we're using them for. +At this layer we're developing methods and functions that perform unsafe operations but have API surfaces which are much more specialized for the tasks we're using them for. This is where we try to keep most of the internal API of the TrieMap. **Top**: The Safe API -Finally, we have the safe API surrounding the data structure that we want end users to actually use. +Finally, we have the safe API surrounding the data structure that we want end users to actually use. This doesn't expose any `unsafe`, as per our requirements. ## Conclusion @@ -260,7 +258,7 @@ This implementation is now in production in the Redis Query Engine! We can note |Microbenchmarks|❌|✅| |Performance|(baseline)|25% to 100% improvement in core operations| -The codebase did double in size, but with a move to a Rust codebase the unsafe operations have been confined to an area of the code a fraction of the size of the C codebase, where unsafe operations could have been happening anywhere. +One interesting part of this is how the codebase doubled in size. Moving to a rust codebase sometimes comes with the expectation of a more expressive, smaller codebase. With a move to a Rust codebase the unsafe operations have been confined to an area of the code a fraction of the size of the C codebase, where unsafe operations could have been happening anywhere. But expressing that Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. @@ -274,4 +272,4 @@ A rewrite is also an opportunity to reassess assumptions. In the original implem We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in unsafe, failing the client on maintainability. We could have come in with a new data structure entirely, failing the client on the performance assumptions made in the rest of their codebase. -The success of this rewrite in terms of speed, performance, and future maintainability is a consequence of taking the client's needs seriously and keeping an open mind on digesting the problems current code solved. \ No newline at end of file +The success of this rewrite in terms of speed, performance, and future maintainability is a consequence of taking the client's needs seriously and keeping an open mind while digesting the problems existing code solved. \ No newline at end of file From 6515fe311989209bc642cae73cd1d45d2f7bcc47 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 20 Apr 2026 14:14:20 +0100 Subject: [PATCH 09/18] Fix unfinished sentence --- src/posts/2026-04-XX-redis-triemap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index c083e2694e..344cf49785 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -236,7 +236,7 @@ At this level, maintainer error is at its most possible. Invariants need to be m **Near-Bottom**: `PtrMetadata` + `PtrWithMetadata` -At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not tracked by the ), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to happen once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. +At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to happen once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. **Mid-Level**: Unsafe methods and functions. From 12e22ea4b563488dbc63f465b9e898989d395bae Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:20:12 +0100 Subject: [PATCH 10/18] Apply suggestions from code review Co-authored-by: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> --- src/posts/2026-04-XX-redis-triemap.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 344cf49785..35a8f52daf 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -12,7 +12,7 @@ A TrieMap is a key-value data structure that has a similar API as a Hashmap / Bi ![Diagram showing how a set of tuples are represented in a TrieMap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) -We can easily imagine how to do this in Rust, we just need to store a data structure in the form: +We can sketch this out in Rust: ```rust struct TrieMapNode { @@ -24,7 +24,7 @@ struct TrieMapNode { } ``` -Very simple stuff! We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. +We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. Let's take a look at the preexisting C implementation: @@ -155,7 +155,7 @@ Rather than C's `malloc` which takes a length in bytes, `alloc` takes a `Layout` `Layout` exposes a fairly simple API, and we can define a layout to suit our complex needs at runtime. 1. `Layout::new` produces a layout for a sized type. -2. `Layout::array` produces a layout for an array of a sized type, with a runtime-or-const `n` elements. +2. `Layout::array` produces a layout for an array of a sized type, with `n` elements. 3. `Layout::extend` allows us to compose layouts together. This composable API of `Layout` means it's fairly easy to build the `Layout` we need for our custom Dynamically Sized Type. @@ -248,7 +248,7 @@ Finally, we have the safe API surrounding the data structure that we want end us ## Conclusion -This implementation is now in production in the Redis Query Engine! We can note & measure some key changes in the codebase from this transition. +This implementation is now in production in Redis Query Engine! We can note and measure some key changes in the codebase from this transition. || C | Rust | |-|-|-| From de7e368d754cec29ecca7a3db3ef0b41f3ae1a6f Mon Sep 17 00:00:00 2001 From: tall-vase Date: Tue, 5 May 2026 16:10:37 +0100 Subject: [PATCH 11/18] Address structural feedback from Luca --- src/posts/2026-04-XX-redis-triemap.md | 129 ++++++++++++++---- .../2026-04-XX-redis-triemap/speedup.svg | 1 + 2 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 static/assets/images/posts/2026-04-XX-redis-triemap/speedup.svg diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 35a8f52daf..b75ba8f3e3 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -2,31 +2,44 @@ --- -At Mainmatter we've been working to help Redis migrate the Redis Query Engine from C to Rust. They've ported some of their existing query engine code already, but this kind of work requires good strategy in both choosing good rewrite candidates and matching the complex behavior and performance characteristics of the original C code. +At Mainmatter we've been working to help Redis migrate the Redis Query Engine from C to Rust. This kind of work requires good strategy in both choosing good rewrite candidates and matching the complex behavior and performance characteristics of the original C code. -This blog post covers +This blog post covers the process of porting the Redis Query Engine's TrieMap implementation to Rust, including the challenges faced with making unsafe code maintainable and designing our own Dynamically Sized Type. -## Case Study: Porting the TrieMap +## What Is a TrieMap -A TrieMap is a key-value data structure that has a similar API as a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. +A TrieMap is a key-value data structure that has a similar API to a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. ![Diagram showing how a set of tuples are represented in a TrieMap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) -We can sketch this out in Rust: +In the above diagram, taken from [the talk this blog post is based on](https://www.youtube.com/watch?v=XklFGy3aUX4), we can see that each node has: + +1. A prefix label that this node and all its children share. +2. Optional data (emoji, here). +3. Child nodes (each with an associated first character.). + +We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. + +We hold onto the first byte of each child's label because, by nature of how shared prefixes are always consolidated by the ancestors of a node, each child is guaranteed to have a unique first byte. This can be leveraged for faster searches within the data structure. + +We can easily imagine how to do this in Rust, we just need to store a data structure in the form: ```rust struct TrieMapNode { label: Vec, - children: Vec, + children_first_bytes: Vec, + children: Vec>, // The data that the label "maps" to. // This is optional, because not all nodes have complete keys with associated data. data: Option, } ``` -We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. +This does what we need it to, at least structurally, so maybe we can stop here? End of the blog post everyone, we can all can go home! Not so fast. A trained eye can see how this formulation of a TrieMap can cause problems. Let's take a step into the real world, where this TrieMap doesn't meet the performance requirements of the Redis Query Engine. -Let's take a look at the preexisting C implementation: +## Porting Redis Query Engine's TrieMap + +Redis Query Engine has its own C implementation of a TrieMap designed around the performance characteristics required for the real world. Let's take a look at it: ```c #pragma pack(1) @@ -45,7 +58,27 @@ typedef struct { The core property of this type is that the fields, label, and the array of pointers to children take up a **single heap allocation**. This is really important for cache locality and minimizing pointer dereferences, but it also means the size of the type and some of the offsets of the fields of that type are not known at compile time. -Note how this type is not fully definable in C's type system, the two lines of comments here are fields that we can't define in a C struct definition and need to compute how to access at runtime. `labelLen` and `numChildren` let us do these computations. +Note how this type is not fully definable in C's type system, the two lines of comments after `label` are fields that we can't define in a C struct definition and need to compute how to access at runtime. `labelLen` and `numChildren` let us do these computations. + +```c +// Simplified, illustrative dynamic field access logic. +TrieMapNode** accessChildren(TrieMapNode* node) { + if (node->numChildren == 0) { + return NULL; + } + // `label` is the final field in the struct, so we know that + // all dynamically located fields are at or past that point. + // + // Add the lengths of the data before the children list. + // Because the labelLen and first letters of each child + // are all `char` (1 byte long) we don't need to multiply + // them by a scalar of the size of those types. + int dynamic_offset_b = node->labelLen + node->numChildren; + return (TrieMapNode**) ((void*)&node->label + dynamic_offset_b); +} +``` + +The above is an example of how we might access the children of a type like this. We know the lengths of each dynamic part of the datatype, so we can compute the offset. ![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) @@ -152,13 +185,48 @@ pub unsafe fn alloc(layout: Layout) -> *mut u8 Rather than C's `malloc` which takes a length in bytes, `alloc` takes a `Layout` type that represents the size and alignment of an allocation. -`Layout` exposes a fairly simple API, and we can define a layout to suit our complex needs at runtime. +A trivial `Layout` for a sized type `T` can be generated via `Layout::new::()`, but `Layout` also exposes a relatively simple to use API: -1. `Layout::new` produces a layout for a sized type. +1. `Layout::new` produces a layout for a sized type (as already stated). 2. `Layout::array` produces a layout for an array of a sized type, with `n` elements. -3. `Layout::extend` allows us to compose layouts together. +3. `Layout::extend` lets us "add" one layout to another, returning that composed layout and an offset for the rhs layout relative to the original layout. Order matters here. -This composable API of `Layout` means it's fairly easy to build the `Layout` we need for our custom Dynamically Sized Type. +We can use this to manually build a runtime-defined layout that suits our Dynamically Sized Type's needs. If we look at our DST's layout needs again: + +1. `label_len` (static size, `u16`) +2. `n_children` (static size, `u8`) +3. `label` (non-static size, `label_len` * `u8`) +4. `children_first_bytes` (non-static size, `n_children` * `u8`) +5. `children` (non-static size, `n_children` * `Node`) +6. `data` (static size, `Option`) + +We have 3 non-static fields and 3 static fields. So to build up our layout we can do the following: + +```rust +/// Simplified layout creation function. In reality we may want to track some +/// of the offsets generated with `extend`, or have more descriptive errors +/// than what is provided by `LayoutError`, or use a `data_present` bool to +/// only allocate the `data` field if it is present. +fn create_triemap_layout(label_len: u16, n_children: u8) -> Result { + let layout = + // layout starts with `label_len` + Layout::new::() + // `n_children` + .extend(Layout::new::())?.0 + // dynamic size for `label` + .extend(Layout::array::(label_len as usize)?)?.0 + // dynamic size for `children_first_bytes` + .extend(Layout::array::(n_children as usize)?)?.0 + // dynamic size for `children` + // size_of::>() == size_of::>() + .extend(Layout::array::>(n_children as usize)?)?.0 + // `data` + .extend(Layout::new::>())?.0; + Ok(layout) +} +``` + +And now that we have a way to express our layout needs at runtime, we can manually call `alloc` to retrieve a pointer. ## Porting Strategy: Managing Necessary Unsafe @@ -170,31 +238,31 @@ Managing this unsafe code requires care and understanding for how developers wil On top of all this, the client is moving to Rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. -To deal with these constraints, we have a core axiom of **no `unsafe` in the public API**, on top of trying to rely on tooling as much as possible. +To deal with these constraints, we have a normative value of **as little `unsafe` in the public API as possible**, on top of trying to rely on automated tooling as much as possible. ### Tooling & Patterns -Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate this process, which is why we have to resort to `unsafe`, but we can do our best to build and maintain certainty. +Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate the unsafe code management process, if we could then it wouldn't be unsafe, but we can do our best to build and maintain certainty through what tools and practices we can bring together. 1. **Miri** -Miri runs your rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. +Miri runs your Rust code in an interpreter against different memory models, checking for undefined behavior as it comes up. A great help when we're writing lots of unsafe code. -This only works on Rust code, so we can't apply this in other parts of Rust Redis where we cross FFI boundaries into C libraries, but we can use it here and anywhere else where all dependencies are rust. +This only works on Rust code, so we can't apply this in other parts of the Redis Query Engine porting effort where we cross FFI boundaries into C libraries. Still, we can use it here and anywhere else where all dependencies are Rust alone. 2. **Debug assertions** -Debug assertions are checks that only run in debug builds, release builds do not pay the performance cost of them. Debug assertions let us both specify and check the invariants we want to build our API around at runtime, and give good errors when those assertions fail. +Debug assertions are checks that only run in debug artefacts (debug builds, tests that otherwise run with optimized build settings). Release artefacts (binaries shipped to the user) do not pay the performance cost of these assertions. Debug assertions let us both specify and check the invariants we want to build our API around at runtime, and give good errors when those assertions fail. Heavy use of debug assertions ties nicely with our use of Miri, whose errors can be overly esoteric or academic. Rust assertions are much easier to contextualize. 3. **Clippy Lints** -Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) we're using clippy lints to enforce two soft invariants: +Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) we're using clippy lints to enforce two invariants: -- 3.1: `undocumented_unsafe_blocks`: If an unsafe block does not have comments surrounding it, there will be a warning. +- 3.1: `undocumented_unsafe_blocks`: every `unsafe` block must have a dedicated `// SAFETY` comment attached to it. -- 3.2: `multiple_unsafe_ops_per_block`: If an unsafe block contains more than one unsafe operation, there will be a warning. +- 3.2: `multiple_unsafe_ops_per_block`: If an `unsafe` block contains more than one `unsafe` operation, there will be a warning. This pushes developers in the direction of properly documenting the unsafe code they do write, operation by operation, to assure all invariants are documented. @@ -204,6 +272,8 @@ Following more of Wrenn's guidance, we're also making sure that each safety comm Enumerated safety comments differ between call sites and declaration sites. Declaration sites need to show the assumptions made (invariants) about inputs and context. Call sites need to explain how those invariants are met, one by one. +[An RFC to formalize this pattern](https://github.com/rust-lang/rfcs/pull/3842) exists, but for now this is a check that maintainers will have to perform through manual comparison. + ```rust // SAFETY: // - The source range is all contained within a single allocation. @@ -236,7 +306,7 @@ At this level, maintainer error is at its most possible. Invariants need to be m **Near-Bottom**: `PtrMetadata` + `PtrWithMetadata` -At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to happen once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. +At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs be provided once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. **Mid-Level**: Unsafe methods and functions. @@ -244,7 +314,7 @@ At this layer we're developing methods and functions that perform unsafe operati **Top**: The Safe API -Finally, we have the safe API surrounding the data structure that we want end users to actually use. This doesn't expose any `unsafe`, as per our requirements. +Finally, we have the safe API surrounding the data structure that we want end users to actually use. This exposes as little `unsafe` as possible, as per our requirements. ## Conclusion @@ -256,13 +326,20 @@ This implementation is now in production in Redis Query Engine! We can note and |Unsafe expressions|-|128| |Coverage|82.8%|95.6%| |Microbenchmarks|❌|✅| -|Performance|(baseline)|25% to 100% improvement in core operations| +|Performance|(Baseline)|Execution time of ~0.88x to 0.33x original (13% to 200% speedup)| + +
+Performance details + +![List of microbenchmark results, showing an across the board improvement in execution times. Lowest increase is the loading function, at 0.88 original speed. Largest increase is leaf insertion, at 0.33 original speed. Most results sit around 0.45-0.75 times original speed.](/assets/images/posts/2026-04-XX-redis-triemap/speedup.svg) + +
-One interesting part of this is how the codebase doubled in size. Moving to a rust codebase sometimes comes with the expectation of a more expressive, smaller codebase. With a move to a Rust codebase the unsafe operations have been confined to an area of the code a fraction of the size of the C codebase, where unsafe operations could have been happening anywhere. But expressing that +One thing that stands out is how the codebase doubled in size. Moving to a rust codebase sometimes comes with the expectation of a more expressive, smaller codebase. With the move to Rust the unsafe operations have been confined to an area of the code a fraction of the size of the original C codebase, where unsafe operations could have been happening anywhere. The abstractions built to confine the unsafe operations mean there has been a bulking out of the module in terms of lines of code, but the payoff is that only ~128 of those lines need to be kept under extra scrutiny. Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. -Performance was also greatly improved, in part from moving away from the packed layout. +Performance was also greatly improved, in part by moving away from the packed layout. Real-world codebases on the level of complexity of the Redis Query Engine require looking carefully into how complex components can be replaced with alternative implementations that better fit the needs of maintainers and users. diff --git a/static/assets/images/posts/2026-04-XX-redis-triemap/speedup.svg b/static/assets/images/posts/2026-04-XX-redis-triemap/speedup.svg new file mode 100644 index 0000000000..73eb9be9d6 --- /dev/null +++ b/static/assets/images/posts/2026-04-XX-redis-triemap/speedup.svg @@ -0,0 +1 @@ + \ No newline at end of file From 3e85c60c7bc6eb7f98c2f68c888f1ac8b0c6bcb2 Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Mon, 11 May 2026 10:41:14 +0100 Subject: [PATCH 12/18] Apply suggestions from code review Co-authored-by: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> --- src/posts/2026-04-XX-redis-triemap.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index b75ba8f3e3..73285ca014 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -2,13 +2,13 @@ --- -At Mainmatter we've been working to help Redis migrate the Redis Query Engine from C to Rust. This kind of work requires good strategy in both choosing good rewrite candidates and matching the complex behavior and performance characteristics of the original C code. +Mainmatter has been working with Redis to migrate Redis Query Engine from C to Rust, module by module. Matching the performance characteristics of the original C code is a non-negotiable requirement: no one likes a slower database, even if it's written in Rust. -This blog post covers the process of porting the Redis Query Engine's TrieMap implementation to Rust, including the challenges faced with making unsafe code maintainable and designing our own Dynamically Sized Type. +This blog post focuses on Redis Query Engine's TrieMap, one of the first C modules we ported over to Rust. In particular, we go over the implementation challenges of designing a custom Dynamically Sized Type (DST) and the maintenance approach we picked for the unsafe code required by a custom DST. ## What Is a TrieMap -A TrieMap is a key-value data structure that has a similar API to a Hashmap / Binary Tree Map, but optimized for compressing keys by their shared prefixes. +A TrieMap is a key-value data structure with an API similar to that of a Hashmap. Keys must be sequence-like types (in our case, byte slices); values can be arbitrary types. TrieMap leverages shared key prefixes to reduce its memory footprint, de facto compressing the key set. ![Diagram showing how a set of tuples are represented in a TrieMap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) @@ -20,7 +20,7 @@ In the above diagram, taken from [the talk this blog post is based on](https://w We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. -We hold onto the first byte of each child's label because, by nature of how shared prefixes are always consolidated by the ancestors of a node, each child is guaranteed to have a unique first byte. This can be leveraged for faster searches within the data structure. +We hold onto the first byte of each child’s label as a performance optimisation: when performing searches (e.g. find all the keys with a given prefix), we can determine which children to visit with minimal pointer chasing. We can easily imagine how to do this in Rust, we just need to store a data structure in the form: @@ -39,7 +39,7 @@ This does what we need it to, at least structurally, so maybe we can stop here? ## Porting Redis Query Engine's TrieMap -Redis Query Engine has its own C implementation of a TrieMap designed around the performance characteristics required for the real world. Let's take a look at it: +Let’s take a look at the node layout of the C implementation we’re trying to replace: ```c #pragma pack(1) @@ -56,7 +56,7 @@ typedef struct { #pragma pack() ``` -The core property of this type is that the fields, label, and the array of pointers to children take up a **single heap allocation**. This is really important for cache locality and minimizing pointer dereferences, but it also means the size of the type and some of the offsets of the fields of that type are not known at compile time. +All node components are laid out next to each other, in a single heap allocation. There is no further indirection. This helps with cache locality and minimizes the number of pointer dereferences, but it also implies that the size of the type and the offsets of some of its fields are not known at compile time. Note how this type is not fully definable in C's type system, the two lines of comments after `label` are fields that we can't define in a C struct definition and need to compute how to access at runtime. `labelLen` and `numChildren` let us do these computations. From abd212937b381dd7470a4b836c40962916be26c1 Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Mon, 11 May 2026 11:10:35 +0100 Subject: [PATCH 13/18] Apply suggestions from code review Co-authored-by: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> --- src/posts/2026-04-XX-redis-triemap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 73285ca014..28f003061f 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -347,6 +347,6 @@ What we get from a rewrite is more than just a codebase translated to a new lang A rewrite is also an opportunity to reassess assumptions. In the original implementation we had a packed layout, we questioned that assumption and went with a different direction, as well as making other small changes in the memory layout of the data structure. Challenging these assumptions paid off, as our new implementation is faster and has a negligible increase in memory use. -We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in unsafe, failing the client on maintainability. We could have come in with a new data structure entirely, failing the client on the performance assumptions made in the rest of their codebase. +We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in unsafe, failing the client on maintainability. The success of this rewrite in terms of speed, performance, and future maintainability is a consequence of taking the client's needs seriously and keeping an open mind while digesting the problems existing code solved. \ No newline at end of file From 7c83e92360b910e09a8983a784f09cde0ebc5e69 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 11 May 2026 12:22:42 +0100 Subject: [PATCH 14/18] Address more feedback. --- src/posts/2026-04-XX-redis-triemap.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 28f003061f..f6b5bf678c 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -18,9 +18,11 @@ In the above diagram, taken from [the talk this blog post is based on](https://w 2. Optional data (emoji, here). 3. Child nodes (each with an associated first character.). -We can "cascade" down nodes from the root to complete the labels of leaves. The data field is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. +We can "cascade" down nodes from the root to recover the labels of leaves, as in the above diagram we recover `"bike": 🚲` from the empty root, the "bi" node, and the "ke" node. -We hold onto the first byte of each child’s label as a performance optimisation: when performing searches (e.g. find all the keys with a given prefix), we can determine which children to visit with minimal pointer chasing. +The data field for each node is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. + +We hold onto the first byte of each child’s label as a performance optimization: when performing searches (e.g. find all the keys with a given prefix), we can determine which children to visit with minimal pointer chasing. We can easily imagine how to do this in Rust, we just need to store a data structure in the form: @@ -195,12 +197,12 @@ We can use this to manually build a runtime-defined layout that suits our Dynami 1. `label_len` (static size, `u16`) 2. `n_children` (static size, `u8`) -3. `label` (non-static size, `label_len` * `u8`) -4. `children_first_bytes` (non-static size, `n_children` * `u8`) -5. `children` (non-static size, `n_children` * `Node`) +3. `label` (dynamic size, `label_len` * `u8`) +4. `children_first_bytes` (dynamic size, `n_children` * `u8`) +5. `children` (dynamic size, `n_children` * `Node`) 6. `data` (static size, `Option`) -We have 3 non-static fields and 3 static fields. So to build up our layout we can do the following: +We have 3 dynamically-sized fields and 3 statically-sized fields (note that by "static" we mean "known at compile-time" not the `'static` lifetime or the `static` keyword). So to build up our layout we can do the following: ```rust /// Simplified layout creation function. In reality we may want to track some @@ -234,9 +236,7 @@ Once we have our `Layout`, we can call `alloc` to get our pointer. This memory i In the final codebase we were finally left with **128** unsafe blocks and **21** unsafe methods. -Managing this unsafe code requires care and understanding for how developers will interact with it. Preferably a developer won't interact with it at all unless they're a maintainer. - -On top of all this, the client is moving to Rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. +Managing this unsafe code requires care and understanding for how maintainers will interact with it. On top of all this, the client is moving to Rust to minimize issues related to memory safety so it is important to build an API that can be trusted and can't be misused. To deal with these constraints, we have a normative value of **as little `unsafe` in the public API as possible**, on top of trying to rely on automated tooling as much as possible. @@ -298,7 +298,7 @@ Unsafe doesn't let users or maintainers do "anything", but it does let them get To strengthen systems built on unsafe foundations, we start by **Layering Abstractions** in a way that gradually reduces the concerns of a maintainer. -The end goal is to have a outwardly safe API, and a internal implementation that restricts what mistakes a maintainer to specific areas of the code. Core, most-unsafe operations are built upon by still-unsafe abstractions that limit the operations the maintainer can perform, and those abstractions are built upon by more-safe abstractions. +Our end goal is to have an API that has a safe surface for developers to use. For developers tasked with maintaining this TrieMap implementation we want there to be different levels of unsafe that have different responsibilities. By splitting up and layering the roles of different unsafe APIs within the crate, maintainers don't have to worry about a combinatorial explosion of potential unsafe interactions. **Bottom**: `alloc`, `Layout`, and pointers. @@ -306,7 +306,7 @@ At this level, maintainer error is at its most possible. Invariants need to be m **Near-Bottom**: `PtrMetadata` + `PtrWithMetadata` -At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs be provided once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. +At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to be provided once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. **Mid-Level**: Unsafe methods and functions. @@ -339,7 +339,7 @@ One thing that stands out is how the codebase doubled in size. Moving to a rust Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. -Performance was also greatly improved, in part by moving away from the packed layout. +Performance improved as well, in part by moving away from the packed layout (expand the "Performance details" section above for detailed numbers). Real-world codebases on the level of complexity of the Redis Query Engine require looking carefully into how complex components can be replaced with alternative implementations that better fit the needs of maintainers and users. From 60a30acf2620eae3c3b3334defbfe4110bd94290 Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Mon, 11 May 2026 16:27:37 +0100 Subject: [PATCH 15/18] Apply suggestions from code review Co-authored-by: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> --- src/posts/2026-04-XX-redis-triemap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index f6b5bf678c..71f3ad80fb 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -24,7 +24,7 @@ The data field for each node is optional as labels can branch without having an We hold onto the first byte of each child’s label as a performance optimization: when performing searches (e.g. find all the keys with a given prefix), we can determine which children to visit with minimal pointer chasing. -We can easily imagine how to do this in Rust, we just need to store a data structure in the form: +We can sketch this out in Rust: ```rust struct TrieMapNode { @@ -242,7 +242,7 @@ To deal with these constraints, we have a normative value of **as little `unsafe ### Tooling & Patterns -Our management of the unsafe code is a mix of good documentation practices for critical areas & automated tooling. We can't fully automate the unsafe code management process, if we could then it wouldn't be unsafe, but we can do our best to build and maintain certainty through what tools and practices we can bring together. +Our management of the unsafe code is a mix of good documentation practices for critical areas and automated tooling. We can't fully automate the unsafe code management process, if we could then it wouldn't be unsafe, but we can do our best to build and maintain certainty through what tools and practices we can bring together. 1. **Miri** From c2c2ffc72ad02be3c7f6a9253e7ff09bde3d10b2 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Mon, 11 May 2026 19:43:28 +0100 Subject: [PATCH 16/18] Minor edits --- src/posts/2026-04-XX-redis-triemap.md | 29 +++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 71f3ad80fb..83dcc72a06 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -63,24 +63,23 @@ All node components are laid out next to each other, in a single heap allocation Note how this type is not fully definable in C's type system, the two lines of comments after `label` are fields that we can't define in a C struct definition and need to compute how to access at runtime. `labelLen` and `numChildren` let us do these computations. ```c -// Simplified, illustrative dynamic field access logic. +// Illustrative dynamic field access logic. TrieMapNode** accessChildren(TrieMapNode* node) { - if (node->numChildren == 0) { - return NULL; - } - // `label` is the final field in the struct, so we know that - // all dynamically located fields are at or past that point. - // - // Add the lengths of the data before the children list. - // Because the labelLen and first letters of each child - // are all `char` (1 byte long) we don't need to multiply - // them by a scalar of the size of those types. - int dynamic_offset_b = node->labelLen + node->numChildren; - return (TrieMapNode**) ((void*)&node->label + dynamic_offset_b); + return ((TrieMapNode **)( + // Address of the node. + (void *)n + // Initial offset is size of the type according to the compiler. + + sizeof(TrieMapNode) + // The label will be a null-term string, so we always advance by at least one past. + + (n->labelLen + 1) + // We also need to skip past the 'first character of each child' data. + + n->numChildren + // And now we have the address of the children, which we typecast to 'TrieMapNode **' + )); } ``` -The above is an example of how we might access the children of a type like this. We know the lengths of each dynamic part of the datatype, so we can compute the offset. +The above is an example of how we might access the children of a type like this. We know the lengths of each dynamic part of the datatype, so we can compute the offset. The original, pre-rust implementation of this can be found [here](https://github.com/RediSearch/RediSearch/blob/dcf009a2327240b24d6efcf100fed577b8a5eef0/deps/triemap/triemap.c#L18). ![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) @@ -262,7 +261,7 @@ Following [Jack Wrenn's guidance](https://jack.wrenn.fyi/blog/safety-hygiene/) w - 3.1: `undocumented_unsafe_blocks`: every `unsafe` block must have a dedicated `// SAFETY` comment attached to it. -- 3.2: `multiple_unsafe_ops_per_block`: If an `unsafe` block contains more than one `unsafe` operation, there will be a warning. +- 3.2: `multiple_unsafe_ops_per_block`: every `unsafe` block can only contain a single `unsafe` operation. This pushes developers in the direction of properly documenting the unsafe code they do write, operation by operation, to assure all invariants are documented. From 97689d78f11f04cb860195baeb2d0fb7bf739448 Mon Sep 17 00:00:00 2001 From: tall-vase <228449146+tall-vase@users.noreply.github.com> Date: Tue, 12 May 2026 11:59:38 +0100 Subject: [PATCH 17/18] Apply suggestions from code review Co-authored-by: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> --- src/posts/2026-04-XX-redis-triemap.md | 35 +++++++++++++++------------ 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index 83dcc72a06..b8a466af3b 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -20,7 +20,7 @@ In the above diagram, taken from [the talk this blog post is based on](https://w We can "cascade" down nodes from the root to recover the labels of leaves, as in the above diagram we recover `"bike": 🚲` from the empty root, the "bi" node, and the "ke" node. -The data field for each node is optional as labels can branch without having an appropriate piece of data at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. +The data field for each node is optional as labels can branch without having an associated payload at the branch point: a map with only the key-value pairs `"Scarborough": 42` and `"Scaffolding": 451` doesn't have associated data for their shared parent `"Sca"`. We hold onto the first byte of each child’s label as a performance optimization: when performing searches (e.g. find all the keys with a given prefix), we can determine which children to visit with minimal pointer chasing. @@ -37,7 +37,7 @@ struct TrieMapNode { } ``` -This does what we need it to, at least structurally, so maybe we can stop here? End of the blog post everyone, we can all can go home! Not so fast. A trained eye can see how this formulation of a TrieMap can cause problems. Let's take a step into the real world, where this TrieMap doesn't meet the performance requirements of the Redis Query Engine. +This does what we need it to, at least structurally, so maybe we can stop here? End of the blog post everyone, we can all can go home! Not so fast. A trained eye can see how this formulation of a TrieMap can cause problems. Let's take a step into the real world, where this TrieMap doesn't meet the performance requirements of Redis Query Engine. ## Porting Redis Query Engine's TrieMap @@ -79,7 +79,7 @@ TrieMapNode** accessChildren(TrieMapNode* node) { } ``` -The above is an example of how we might access the children of a type like this. We know the lengths of each dynamic part of the datatype, so we can compute the offset. The original, pre-rust implementation of this can be found [here](https://github.com/RediSearch/RediSearch/blob/dcf009a2327240b24d6efcf100fed577b8a5eef0/deps/triemap/triemap.c#L18). +The above is an example of how we might access the children of a type like this. We know the lengths of each dynamic part of the datatype, so we can compute the offset. The original, pre-Rust implementation of this can be found [here](https://github.com/RediSearch/RediSearch/blob/dcf009a2327240b24d6efcf100fed577b8a5eef0/deps/triemap/triemap.c#L18). ![](/assets/images/posts/2026-04-XX-redis-triemap/c-layout.svg) @@ -106,17 +106,20 @@ struct ChildRef { } ``` -This implementation makes no attempt to match the performance characteristics of the C original, so we have extra heap allocations for labels and children to maintain the simplicity. +This implementation makes no attempt to match the performance characteristics of the C original, so we have extra heap allocations for labels and children to keep things simple. -Building off of this naive implementation, we can design an external API that can be tested and benchmarked. This working, tested, naive version and refresh of what the original was doing lowers the cognitive load when it comes to the team implementing a later version. +Building off of this naive implementation, we can design an external API that can be tested and benchmarked. This working, tested, naive version reduces cognitive load for the team when implementing a more optimized version later on. -We didn't focus on performance, because that wasn't what this initial information-gathering pass was about. But we might as well make the comparisons we can so we know how important the optimization work will be: +We didn't focus on performance, because that wasn't what this initial information-gathering pass was about. But we might as well make the comparisons so we know how important the optimization work will be: ![](/assets/images/posts/2026-04-XX-redis-triemap/violin-chart.svg) -Our implementation was twice as slow as the original C implementation, but about half as slow as an off-the-shelf crate from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the instances of `Vec` we used also meant the memory usage of the naive implementation was double that of the original. +Our implementation was twice as slow as the original C implementation but about twice as fast as an off-the-shelf implementation from crates.io. Our implementation was also far less consistent in its speed, whereas the C implementation had very little variance. All the instances of `Vec` we used also showed that the naive implementation's memory usage was double that of the C original. -This first step in the porting strategy left us with 1. Total test coverage of what we were working on 2. Knowledge of how subpar the performance of this naive TrieMap implementation and 3. A team that has a deeper understanding of the original implementation and the decisions behind it. +This first step in the porting strategy left us with: +1. Total test coverage of what we were working on +2. Knowledge of how subpar the performance of this naive TrieMap implementation is +3. A team that has a deeper understanding of the original implementation and the decisions behind it. ## Porting Strategy: Iterating for Performance @@ -135,11 +138,11 @@ There are a couple of differences between our Rust layout and the original C lay 1. The data structure is padded, so depending on the number of children there may be unused space between the end of `children_first_bytes` and `children`. 2. Our optional leaf data sits at the end of the struct, rather than before the label and children. -How are we supposed to implement this in Rust? We do it with designing our own Dynamically Sized Types. +How are we supposed to implement this in Rust? We do it with designing our own Dynamically Sized Type (DST). ### DIY Dynamically Sized Types -We want nodes to be a Dynamically Sized Type, like `str` or `[u8]` (note the lack of `&`). Something whose size is not known at compile-time. +We want nodes to be a DST, like `str` or `[u8]` (note the lack of `&`). Something whose size is not known at compile-time. Our ideal Rust implementation would look something like this: @@ -190,18 +193,18 @@ A trivial `Layout` for a sized type `T` can be generated via `Layout::new::() 1. `Layout::new` produces a layout for a sized type (as already stated). 2. `Layout::array` produces a layout for an array of a sized type, with `n` elements. -3. `Layout::extend` lets us "add" one layout to another, returning that composed layout and an offset for the rhs layout relative to the original layout. Order matters here. +3. `Layout::extend` lets us "add" one layout to another, returning that composed layout and an offset for the right-hand side layout relative to the original layout. Order matters here. -We can use this to manually build a runtime-defined layout that suits our Dynamically Sized Type's needs. If we look at our DST's layout needs again: +We can use this to manually build a runtime-defined layout that suits our DST. If we look at what our DST layout needs again: -1. `label_len` (static size, `u16`) -2. `n_children` (static size, `u8`) +1. `label_len` (known size, `u16`) +2. `n_children` (known size, `u8`) 3. `label` (dynamic size, `label_len` * `u8`) 4. `children_first_bytes` (dynamic size, `n_children` * `u8`) 5. `children` (dynamic size, `n_children` * `Node`) -6. `data` (static size, `Option`) +6. `data` (known size, dynamic offset, `Option`) -We have 3 dynamically-sized fields and 3 statically-sized fields (note that by "static" we mean "known at compile-time" not the `'static` lifetime or the `static` keyword). So to build up our layout we can do the following: +We have 3 dynamically-sized fields and 3 fields with sizes known at compile-time. So to build up our layout we can do the following: ```rust /// Simplified layout creation function. In reality we may want to track some From 44515cf5928eca0eda69363c8c1ea498f41b5d96 Mon Sep 17 00:00:00 2001 From: tall-vase Date: Tue, 12 May 2026 12:29:15 +0100 Subject: [PATCH 18/18] Links to hash/triemap + be consistent with --- src/posts/2026-04-XX-redis-triemap.md | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/posts/2026-04-XX-redis-triemap.md b/src/posts/2026-04-XX-redis-triemap.md index b8a466af3b..ed5cbb20e2 100644 --- a/src/posts/2026-04-XX-redis-triemap.md +++ b/src/posts/2026-04-XX-redis-triemap.md @@ -4,11 +4,11 @@ Mainmatter has been working with Redis to migrate Redis Query Engine from C to Rust, module by module. Matching the performance characteristics of the original C code is a non-negotiable requirement: no one likes a slower database, even if it's written in Rust. -This blog post focuses on Redis Query Engine's TrieMap, one of the first C modules we ported over to Rust. In particular, we go over the implementation challenges of designing a custom Dynamically Sized Type (DST) and the maintenance approach we picked for the unsafe code required by a custom DST. +This blog post focuses on [Redis Query Engine's TrieMap](https://github.com/RediSearch/RediSearch/tree/c8785e670af0c8ce9d48ad4d6b5596c2078f9420/src/redisearch_rs/trie_rs), one of the first C modules we ported over to Rust. In particular, we go over the implementation challenges of designing a custom Dynamically Sized Type (DST) and the maintenance approach we picked for the unsafe code required by a custom DST. ## What Is a TrieMap -A TrieMap is a key-value data structure with an API similar to that of a Hashmap. Keys must be sequence-like types (in our case, byte slices); values can be arbitrary types. TrieMap leverages shared key prefixes to reduce its memory footprint, de facto compressing the key set. +A TrieMap is a key-value data structure with an API similar to that of a [Hashmap](https://doc.rust-lang.org/std/collections/struct.HashMap.html). Keys must be sequence-like types (in our case, byte slices); values can be arbitrary types. TrieMap leverages shared key prefixes to reduce its memory footprint, de facto compressing the key set. ![Diagram showing how a set of tuples are represented in a TrieMap.](/assets/images/posts/2026-04-XX-redis-triemap/trie.svg) @@ -232,23 +232,23 @@ fn create_triemap_layout(label_len: u16, n_children: u8) -> Result` + `PtrWithMetadata` -At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to be provided once, when `PtrWithMetadata` is constructed using the unsafe methods that construct it. +At this level, we have the metadata needed to construct a pointer (`PtrMetadata`, which holds a `Layout` and the offsets of the fields not able to be tracked directly by the type system), as well as a version of the constructed pointer that is paired with its metadata. This pairing means a proof of the relationship between the pointer and the metadata of that pointer only needs to be provided once, when `PtrWithMetadata` is constructed using the `unsafe` methods that construct it. -**Mid-Level**: Unsafe methods and functions. +**Mid-Level**: `unsafe` methods and functions. -At this layer we're developing methods and functions that perform unsafe operations but have API surfaces which are much more specialized for the tasks we're using them for. This is where we try to keep most of the internal API of the TrieMap. +At this layer we're developing methods and functions that perform `unsafe` operations but have API surfaces which are much more specialized for the tasks we're using them for. This is where we try to keep most of the internal API of the TrieMap. **Top**: The Safe API @@ -325,7 +325,7 @@ This implementation is now in production in Redis Query Engine! We can note and || C | Rust | |-|-|-| |Lines of Code|~1k|~1.9k| -|Unsafe expressions|-|128| +|`unsafe` expressions|-|128| |Coverage|82.8%|95.6%| |Microbenchmarks|❌|✅| |Performance|(Baseline)|Execution time of ~0.88x to 0.33x original (13% to 200% speedup)| @@ -337,7 +337,7 @@ This implementation is now in production in Redis Query Engine! We can note and -One thing that stands out is how the codebase doubled in size. Moving to a rust codebase sometimes comes with the expectation of a more expressive, smaller codebase. With the move to Rust the unsafe operations have been confined to an area of the code a fraction of the size of the original C codebase, where unsafe operations could have been happening anywhere. The abstractions built to confine the unsafe operations mean there has been a bulking out of the module in terms of lines of code, but the payoff is that only ~128 of those lines need to be kept under extra scrutiny. +One thing that stands out is how the codebase doubled in size. Moving to a rust codebase sometimes comes with the expectation of a more expressive, smaller codebase. With the move to Rust the `unsafe`-equivalent operations have been confined to an area of the code a fraction of the size of the original C codebase, where `unsafe`-equivalent operations could have been happening anywhere. The abstractions built to confine the `unsafe` operations mean there has been a bulking out of the module in terms of lines of code, but the payoff is that only ~128 of those lines need to be kept under extra scrutiny. Test coverage was greatly improved, the last percentage points in coverage are in unreachable areas. @@ -349,6 +349,6 @@ What we get from a rewrite is more than just a codebase translated to a new lang A rewrite is also an opportunity to reassess assumptions. In the original implementation we had a packed layout, we questioned that assumption and went with a different direction, as well as making other small changes in the memory layout of the data structure. Challenging these assumptions paid off, as our new implementation is faster and has a negligible increase in memory use. -We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in unsafe, failing the client on maintainability. +We could have stopped at our naive implementation, failing the client on performance. We could have wrapped large areas of the codebase in `unsafe`, failing the client on maintainability. The success of this rewrite in terms of speed, performance, and future maintainability is a consequence of taking the client's needs seriously and keeping an open mind while digesting the problems existing code solved. \ No newline at end of file