Agent skill
review-rust-memory
Review and enforce Rust memory optimization patterns. Use when writing, reviewing, or debugging Rust code for memory efficiency. Covers allocation reduction, data layout, string handling, collection sizing, Copy-on-Write, arena allocation, stack vs heap tradeoffs, zero-copy parsing, memory profiling, and leak prevention.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/review-rust-memory
SKILL.md
Rust Memory Optimization Patterns, Footguns & Standards
Context: Feather-Flow is a schema validation framework with static analysis as a first-class citizen. See HOW_FEATHERFLOW_WORKS.md for the full architecture. Memory efficiency matters most in the compile pipeline: schema propagation walks the entire DAG, DataFusion plans every SQL model, and analysis passes run per-model — so allocation costs scale with model count.
A practical reference for writing memory-efficient Rust — common anti-patterns, the correct alternatives, and rules to enforce in code review.
Core Rules
- Measure before optimizing —
DHAT,heaptrack, orjemallocprofiling before guessing - Minimize allocations — the fastest allocation is the one that doesn't happen
- Right-size your collections —
Vec::with_capacity, not push-and-pray Cow<str>overStringclones — borrow when you can, own when you must- Stack over heap —
SmallVec,ArrayString, inline buffers for small, bounded data - Zero-copy parsing — borrow from the input buffer, don't allocate per field
- Shrink your types — field ordering, smaller integer types,
Box<[T]>overVec<T>for sealed data - Drop early, drop explicitly — release memory as soon as you're done with it
- Flatten nested allocations —
Vec<Vec<T>>is two allocations per inner vec; flatten when possible - Profile your allocator —
jemallocormimalloccan halve fragmentation in long-running processes - Avoid
Stringfor fixed vocabularies — enums, interning, or&'static str - No
clone()as a reflex — restructure ownership, use references, share withArc
Rule 1: Measure Before Optimizing
Memory optimization without profiling is guesswork. Rust's ownership model prevents leaks, but it doesn't prevent excessive allocation, fragmentation, or bloated data structures.
Tools
| Tool | What it tells you |
|---|---|
DHAT (via dhat-rs) |
Allocation count, total bytes, peak live bytes, allocation hotspots |
heaptrack |
Allocation timeline, flamegraphs of allocation sites, leak detection |
jemalloc malloc_stats |
Fragmentation, active vs mapped memory, arena utilization |
std::mem::size_of::<T>() |
Compile-time struct size (includes padding) |
std::mem::align_of::<T>() |
Alignment requirement (drives padding) |
/proc/self/status (Linux) |
VmRSS (resident set), VmPeak, VmSwap |
The Rule
Never optimize memory without profiling first. Add
dhat-rsas a dev dependency, run your hottest code path under it, and fix the top allocation sites.rust// In a benchmark or integration test: #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; fn main() { let _profiler = dhat::Profiler::new_heap(); // ... run workload ... // Drop _profiler to print stats }
Rule 2: Minimize Allocations
Every Box::new, Vec::push (that grows), String::from, and format! hits the allocator. In hot paths, the allocator is often the bottleneck.
The Footgun
// BAD — allocation per iteration
fn process_items(items: &[Item]) -> Vec<String> {
let mut results = Vec::new();
for item in items {
let label = format!("item-{}", item.id); // allocates a String
let upper = label.to_uppercase(); // allocates another String
results.push(upper); // may reallocate the Vec
}
results
}
The Fix
// GOOD — pre-allocate, reuse buffer
fn process_items(items: &[Item]) -> Vec<String> {
let mut results = Vec::with_capacity(items.len());
let mut buf = String::with_capacity(64);
for item in items {
buf.clear();
use std::fmt::Write;
write!(&mut buf, "item-{}", item.id).unwrap();
results.push(buf.to_uppercase());
}
results
}
// EVEN BETTER — return references if the caller doesn't need ownership
fn process_items(items: &[Item]) -> Vec<String> {
items.iter()
.map(|item| format!("ITEM-{}", item.id))
.collect() // single allocation via collect's size_hint
}
The Rule
In hot paths, count your allocations. Each
format!,to_string(),to_owned(),clone(), andvec![]is a call to the allocator.
- Reuse buffers with
.clear()instead of creating new oneswrite!into an existingStringinstead offormat!creating a new onecollect()withsize_hintis one allocation; a loop withpushmay be many
Rule 3: Right-Size Your Collections
Vec starts at capacity 0, then grows by doubling (0 → 4 → 8 → 16 → 32...). Each growth copies all elements to a new, larger buffer and frees the old one. For a 1000-element vec built with push, that's ~10 reallocations and ~2000 elements copied.
The Footgun
// BAD — 10+ reallocations for known-size data
fn collect_names(users: &[User]) -> Vec<String> {
let mut names = Vec::new();
for user in users {
names.push(user.name.clone());
}
names
}
// BAD — HashMap resizes 5+ times building a known-size map
fn build_index(items: &[Item]) -> HashMap<u64, &Item> {
let mut map = HashMap::new();
for item in items {
map.insert(item.id, item);
}
map
}
The Fix
// GOOD — single allocation, no reallocations
fn collect_names(users: &[User]) -> Vec<String> {
let mut names = Vec::with_capacity(users.len());
for user in users {
names.push(user.name.clone());
}
names
}
// BETTER — collect uses size_hint automatically
fn collect_names(users: &[User]) -> Vec<String> {
users.iter().map(|u| u.name.clone()).collect()
}
// GOOD — HashMap with capacity
fn build_index(items: &[Item]) -> HashMap<u64, &Item> {
let mut map = HashMap::with_capacity(items.len());
for item in items {
map.insert(item.id, item);
}
map
}
Sealed collections: Vec<T> → Box<[T]>
When a collection won't be modified after creation, convert it to a boxed slice. This drops the capacity field (saving 8 bytes on the stack) and shrinks the heap allocation to exact size.
// GOOD — sealed data, no wasted capacity
let items: Box<[Item]> = build_items().into_boxed_slice();
The Rule
Always use
with_capacitywhen the size is known or estimable.collect()does this automatically for iterators with accuratesize_hint.
Vec::with_capacity(n)— one allocationHashMap::with_capacity(n)— one allocationString::with_capacity(n)— for string building- After building,
.into_boxed_slice()or.shrink_to_fit()to release excess capacity
Rule 4: Cow<str> Over String Clones
Cow<'a, str> (Clone-on-Write) borrows when possible and only allocates when mutation is needed. This is the single biggest memory win for functions that usually pass data through unchanged.
The Footgun
// BAD — clones the string even when no modification is needed
fn normalize(input: &str) -> String {
if input.contains('\t') {
input.replace('\t', " ") // allocates only when tabs exist
} else {
input.to_string() // UNNECESSARY allocation
}
}
// BAD — forces every caller to allocate
fn process(name: String) {
println!("{name}");
}
let s = "hello";
process(s.to_string()); // pointless allocation
The Fix
// GOOD — borrows when unchanged, allocates only when modified
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
Cow::Borrowed(input)
}
}
// GOOD — accept borrowed or owned
fn process(name: &str) {
println!("{name}");
}
// ALSO GOOD — when you need flexibility
fn process(name: impl AsRef<str>) {
println!("{}", name.as_ref());
}
The Rule
Use
Cow<str>for functions that sometimes modify strings and sometimes don't. The borrow case is zero-allocation.
- Return
Cow<'_, str>instead ofStringfrom transformation functions- Accept
&strorimpl AsRef<str>instead ofStringfor read-only parametersCow::Borrowedfor the fast path (no change),Cow::Ownedfor the slow path (modification needed)
Rule 5: Stack Over Heap for Small, Bounded Data
Heap allocation involves a syscall (or at minimum, allocator bookkeeping). For small, fixed-size data, the stack is free.
The Footgun
// BAD — heap allocation for a tiny, bounded vec
fn get_keywords(input: &str) -> Vec<&str> {
input.splitn(5, ',').collect() // at most 5 elements, but Vec heap-allocates
}
// BAD — String for a bounded identifier
struct Model {
name: String, // model names are ≤64 chars by convention
}
The Fix
// GOOD — SmallVec stays on the stack for small counts
use smallvec::SmallVec;
fn get_keywords(input: &str) -> SmallVec<[&str; 8]> {
input.splitn(5, ',').collect() // no heap allocation for ≤8 elements
}
// GOOD — ArrayString for bounded strings (from the `arrayvec` crate)
use arrayvec::ArrayString;
struct Model {
name: ArrayString<64>, // 64 bytes on the stack, no heap allocation
}
// GOOD — tinyvec for no-dependency alternative
use tinyvec::ArrayVec;
fn get_pair(a: u32, b: u32) -> ArrayVec<[u32; 4]> {
let mut v = ArrayVec::new();
v.push(a);
v.push(b);
v
}
When to use what
| Data | Stack option | When heap is fine |
|---|---|---|
| ≤ ~8 items, known bound | SmallVec<[T; N]> |
Unknown or large count |
| ≤ ~128 byte string, known bound | ArrayString<N> |
Unbounded user input |
| Fixed small count | ArrayVec<[T; N]> |
Dynamic size needed |
| Enum variants, status codes | Inline in the struct | Never needs heap |
The Rule
If the data has a small, known upper bound, keep it on the stack.
SmallVec,ArrayVec, andArrayStringavoid the allocator entirely for the common case.
Rule 6: Zero-Copy Parsing
Parsing a large input buffer into a structure that borrows from the buffer — instead of copying substrings — can reduce memory usage by 2-10x.
The Footgun
// BAD — copies every field out of the input
struct Record {
name: String,
value: String,
}
fn parse_records(input: &str) -> Vec<Record> {
input.lines()
.map(|line| {
let (name, value) = line.split_once('=').unwrap();
Record {
name: name.to_string(), // allocation
value: value.to_string(), // allocation
}
})
.collect()
}
The Fix
// GOOD — borrows from the input buffer, zero allocations per field
struct Record<'a> {
name: &'a str,
value: &'a str,
}
fn parse_records(input: &str) -> Vec<Record<'_>> {
input.lines()
.filter_map(|line| {
let (name, value) = line.split_once('=')?;
Some(Record {
name: name.trim(),
value: value.trim(),
})
})
.collect() // one allocation for the Vec, zero for the records
}
// GOOD — serde zero-copy deserialization
use serde::Deserialize;
#[derive(Deserialize)]
struct Event<'a> {
#[serde(borrow)]
event_type: &'a str,
#[serde(borrow)]
payload: &'a serde_json::value::RawValue,
}
The Rule
When parsing structured data, borrow from the source buffer. Lifetimes are free; allocations are not.
- Use
&'a strfields instead ofStringwhen the source outlives the parsed structure#[serde(borrow)]for zero-copy JSON/YAML deserialization- If ownership is needed later, provide an
.into_owned()method
Rule 7: Shrink Your Types
Every byte in a struct is multiplied by every instance. A 4-byte savings on a type with 1M instances saves 4MB.
Field ordering and padding
Rust lays out struct fields with alignment padding. Field order matters.
// BAD — 24 bytes (with padding)
struct Bad {
a: u8, // 1 byte + 7 padding
b: u64, // 8 bytes
c: u8, // 1 byte + 7 padding
}
// GOOD — 16 bytes (fields ordered large to small)
struct Good {
b: u64, // 8 bytes
a: u8, // 1 byte
c: u8, // 1 byte + 6 padding
}
// Check: assert_eq!(std::mem::size_of::<Good>(), 16);
Smaller integer types
// BAD — 8 bytes for a port number
struct Config {
port: u64, // max 65535, u64 is wasteful
}
// GOOD — 2 bytes
struct Config {
port: u16,
}
Box<T> for large enum variants
// BAD — the entire enum is as large as the biggest variant
enum Ast {
Literal(i64), // 8 bytes
BinaryOp { left: Box<Ast>, op: Op, right: Box<Ast> }, // 24 bytes
Function { name: String, args: Vec<Ast> }, // 48 bytes ← inflates all variants
}
// GOOD — Box the large variant
enum Ast {
Literal(i64),
BinaryOp { left: Box<Ast>, op: Op, right: Box<Ast> },
Function(Box<FunctionCall>), // 8 bytes (pointer)
}
struct FunctionCall {
name: String,
args: Vec<Ast>,
}
Option<NonZero*> for niche optimization
use std::num::NonZeroU32;
// size_of::<Option<u32>>() == 8 (tag + value)
// size_of::<Option<NonZeroU32>>() == 4 (niche: 0 represents None)
struct Record {
id: Option<NonZeroU32>, // 4 bytes instead of 8
}
The Rule
Order struct fields from largest alignment to smallest. Use
size_ofandalign_ofto verify.
- Large enum variants →
Boxthe payload- Optional integers →
NonZeroU*for niche optimization- Counts that fit in
u32don't needusize(on 64-bit, saves 4 bytes each)- After optimization, assert sizes in a test:
assert_eq!(size_of::<MyType>(), expected)
Rule 8: Drop Early, Drop Explicitly
Rust drops values at the end of their scope. If you're done with a large allocation halfway through a function, it sits in memory until the function returns.
The Footgun
// BAD — `raw_data` lives until the function returns
fn process(path: &Path) -> Result<Summary> {
let raw_data = std::fs::read_to_string(path)?; // could be 100MB
let parsed = parse(&raw_data);
let summary = summarize(&parsed);
// ... 200 more lines of computation ...
// raw_data is still alive, holding 100MB
Ok(summary)
}
The Fix
// GOOD — drop raw_data as soon as parsing is done
fn process(path: &Path) -> Result<Summary> {
let parsed = {
let raw_data = std::fs::read_to_string(path)?;
parse(&raw_data)
}; // raw_data dropped here
let summary = summarize(&parsed);
// ... rest of computation with 100MB freed ...
Ok(summary)
}
// ALSO GOOD — explicit drop
fn process(path: &Path) -> Result<Summary> {
let raw_data = std::fs::read_to_string(path)?;
let parsed = parse(&raw_data);
drop(raw_data); // explicit: free the 100MB now
let summary = summarize(&parsed);
Ok(summary)
}
The Rule
Scope or
drop()large allocations as soon as you're done with them. Don't let 100MB sit around because you still need a 1KB summary derived from it.
- Inner blocks
{ ... }for scoped lifetimesdrop(x)for explicit deallocationVec::clear()+Vec::shrink_to_fit()if you need to reuse the variable but release memory
Rule 9: Flatten Nested Allocations
Vec<Vec<T>> is one allocation for the outer vec plus one allocation per inner vec. For 10,000 inner vecs, that's 10,001 heap allocations and scattered memory (poor cache locality).
The Footgun
// BAD — 10,001 allocations for 10,000 groups
fn group_by_key(items: &[Item]) -> Vec<Vec<&Item>> {
let mut groups: HashMap<u64, Vec<&Item>> = HashMap::new();
for item in items {
groups.entry(item.key).or_default().push(item);
}
groups.into_values().collect()
}
// BAD — String per row per column
struct Table {
rows: Vec<Vec<String>>, // N*M allocations
}
The Fix
// GOOD — flat buffer with index
struct FlatGroups<'a> {
items: Vec<&'a Item>, // one allocation
offsets: Vec<usize>, // one allocation (group boundaries)
}
impl<'a> FlatGroups<'a> {
fn group(&self, i: usize) -> &[&'a Item] {
let start = self.offsets[i];
let end = self.offsets.get(i + 1).copied().unwrap_or(self.items.len());
&self.items[start..end]
}
}
// GOOD — single String buffer with slices for a table
struct Table {
buffer: String, // one allocation for all cell data
cells: Vec<(usize, usize)>, // (start, len) into buffer — one allocation
columns: usize,
}
The Rule
Flatten
Vec<Vec<T>>into a singleVec<T>+ offset array when the inner vecs are built once and read many times. This reduces allocations from N+1 to 2 and improves cache locality.
Rule 10: Choose Your Allocator
The default system allocator (malloc/free) is general-purpose but not always optimal. For long-running processes with varied allocation patterns, a purpose-built allocator can significantly reduce fragmentation and peak RSS.
Options
// jemalloc — good for long-running servers, reduces fragmentation
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
// mimalloc — good general-purpose performance, lower latency
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
Arena allocation for batch processing
When you allocate many small objects that all share the same lifetime (e.g., AST nodes during parsing), arena allocation is ideal — one bulk allocation, one bulk deallocation, zero individual frees.
use bumpalo::Bump;
fn parse_ast<'a>(bump: &'a Bump, source: &str) -> &'a AstNode<'a> {
let node = bump.alloc(AstNode {
kind: NodeKind::Root,
children: bumpalo::vec![in bump],
});
// ... parse into bump-allocated nodes ...
node
}
// All allocations freed at once when `bump` is dropped
The Rule
For CLI tools processing batches, arenas (
bumpalo) eliminate per-object free overhead. For long-running processes,jemallocormimallocreduce fragmentation.
- Benchmark before switching — the default allocator is fine for many workloads
- Arena allocation is ideal when all objects share a lifetime (parsing, compilation passes)
Rule 11: Avoid String for Fixed Vocabularies
If a value comes from a known, finite set, a heap-allocated String is pure waste.
The Footgun
// BAD — heap allocation for a value that's always one of 5 options
struct Column {
data_type: String, // "integer", "text", "boolean", "float", "timestamp"
}
// BAD — storing the same string thousands of times
struct Record {
status: String, // "active" appears 50,000 times → 50,000 allocations
}
The Fix
// GOOD — enum is zero bytes of heap allocation
enum DataType {
Integer,
Text,
Boolean,
Float,
Timestamp,
}
// GOOD — &'static str for open-ended but long-lived strings
struct Column {
data_type: &'static str,
}
// GOOD — string interning for dynamic but repetitive strings
use string_interner::StringInterner;
struct Schema {
interner: StringInterner,
columns: Vec<InternedColumn>,
}
struct InternedColumn {
name: DefaultSymbol, // 4 bytes, not 24+ for a String
data_type: DefaultSymbol, // shares storage with other columns
}
The Rule
Fixed vocabulary → enum. Repeated strings → interning. Static strings →
&'static str.
Stringis for genuinely dynamic, user-provided data- Enums have zero allocation cost and enable exhaustive matching
- Interning eliminates duplicate storage for repeated values
Rule 12: No clone() as a Reflex
clone() is an allocation (for heap types). Every clone() in a hot path should be justified.
The Footgun
// BAD — cloning to satisfy the borrow checker
fn process(data: &Data) -> Result<Output> {
let name = data.name.clone(); // why?
let items = data.items.clone(); // 1000 items cloned
transform(&name, &items)
}
// BAD — cloning in a loop
fn find_matches(haystack: &[Record], needle: &str) -> Vec<Record> {
haystack.iter()
.filter(|r| r.name == needle)
.cloned() // clones every matching record
.collect()
}
The Fix
// GOOD — borrow instead of clone
fn process(data: &Data) -> Result<Output> {
transform(&data.name, &data.items)
}
// GOOD — return references, let caller decide about ownership
fn find_matches<'a>(haystack: &'a [Record], needle: &str) -> Vec<&'a Record> {
haystack.iter()
.filter(|r| r.name == needle)
.collect()
}
// GOOD — Arc for shared ownership without cloning the data
fn process(data: Arc<Data>) -> Result<Output> {
let data_ref = Arc::clone(&data); // cheap reference count increment
transform(&data_ref.name, &data_ref.items)
}
The Rule
Every
clone()should answer: "Why can't this be a reference?"
- Borrow (
&T) when you just need to readArc<T>when multiple owners need shared accessCow<'_, T>when you usually borrow but sometimes need to ownclone()only when ownership transfer is genuinely required
Summary Cheat Sheet
| Rule | Anti-pattern | Fix |
|---|---|---|
| Measure first | Guessing at hotspots | dhat-rs, heaptrack, size_of |
| Minimize allocations | format! in hot loops |
Reuse buffers, write! into existing String |
| Right-size collections | Vec::new() + repeated push |
Vec::with_capacity(n), collect() |
| Cow over clone | input.to_string() when unmodified |
Cow::Borrowed(input) |
| Stack over heap | Vec for ≤8 items |
SmallVec, ArrayVec, ArrayString |
| Zero-copy parsing | String fields from parsed input |
&'a str fields, #[serde(borrow)] |
| Shrink types | Random field order, oversized ints | Largest-first ordering, u16/u32, Box big variants |
| Drop early | Large data lives to end of scope | Inner blocks, explicit drop() |
| Flatten nested allocs | Vec<Vec<T>> |
Flat Vec<T> + offset array |
| Choose allocator | Default malloc for everything | jemalloc/mimalloc, bumpalo for arenas |
| Avoid String for enums | String for fixed options |
enum, &'static str, interning |
| No reflex clone | .clone() to appease borrow checker |
References, Arc, Cow, restructured ownership |
Feather-Flow-Specific Patterns
These patterns address the concrete memory hotspots in this codebase. They apply during schema propagation, compilation, and static analysis — the hot paths where model count is the multiplier.
FF-1: Arc<RelSchema> for the Schema Catalog
RelSchema (containing Vec<TypedColumn>, each with Vec<ColumnProvenance>) is cloned 2-3 times per model during schema propagation. With N models, that's 2N-3N deep clones of nested Vecs.
// BAD — current pattern: clone into two separate maps
schema_catalog.insert(name.to_string(), rel_schema.clone());
yaml_schemas.insert(name.clone(), rel_schema);
// Then AGAIN when converting for propagation:
let yaml_string_map: HashMap<String, RelSchema> = yaml_schemas
.iter()
.map(|(k, v)| (k.to_string(), v.clone())) // clones ALL schemas again
.collect();
// GOOD — share ownership with Arc
let shared = Arc::new(rel_schema);
schema_catalog.insert(name.to_string(), Arc::clone(&shared));
yaml_schemas.insert(name.clone(), shared);
// Conversion becomes cheap:
let yaml_string_map: HashMap<String, Arc<RelSchema>> = yaml_schemas
.iter()
.map(|(k, v)| (k.to_string(), Arc::clone(v))) // ref count bump, not deep clone
.collect();
FF-2: Build Function Registries Once
FeatherFlowProvider rebuilds ~45 UDF HashMap entries per model. For 100 models, that's 4,500 HashMap insertions with String key allocations — for the same static set of functions.
// BAD — current pattern: rebuild per model in topological loop
for model in &topo_order {
let provider = FeatherFlowProvider::with_user_functions(
&catalog, user_functions, user_table_functions,
);
// Inside: duckdb_scalar_udfs() creates Vec of Arc<ScalarUDF>
// Then collects into HashMap<String, Arc<ScalarUDF>> — per model
}
// GOOD — build once, share by reference
let scalar_fns: HashMap<String, Arc<ScalarUDF>> = functions::duckdb_scalar_udfs()
.into_iter()
.map(|f| (f.name().to_uppercase(), f))
.collect();
let aggregate_fns: HashMap<String, Arc<AggregateUDF>> = functions::duckdb_aggregate_udfs()
.into_iter()
.map(|f| (f.name().to_uppercase(), f))
.collect();
for model in &topo_order {
let provider = FeatherFlowProvider::with_prebuilt_functions(
&catalog, &scalar_fns, &aggregate_fns, user_functions,
);
}
FF-3: Cache Arrow Schema Conversions
rel_schema_to_arrow() converts RelSchema → Arrow SchemaRef on every get_table_source() call. If model A references upstream B three times, B's schema is converted three times.
// BAD — convert on every lookup
fn get_table_source(&self, name: &str) -> Option<SchemaRef> {
let schema = self.catalog.get(name)?;
Some(rel_schema_to_arrow(schema)) // allocates Fields + Arc<Schema>
}
// GOOD — cache conversions
struct FeatherFlowProvider<'a> {
catalog: &'a SchemaCatalog,
arrow_cache: HashMap<String, SchemaRef>, // SchemaRef is Arc<Schema>, cheap to clone
}
fn get_table_source(&mut self, name: &str) -> Option<SchemaRef> {
if let Some(cached) = self.arrow_cache.get(name) {
return Some(Arc::clone(cached));
}
let schema = self.catalog.get(name)?;
let arrow = rel_schema_to_arrow(schema);
self.arrow_cache.insert(name.to_string(), Arc::clone(&arrow));
Some(arrow)
}
FF-4: Pre-Compute Lookup Maps Once
categorize_dependencies() rebuilds a lowercase lookup map from known_models on every call — once per model per compilation.
// BAD — O(models²) string allocations
fn categorize_dependencies(
deps: HashSet<String>,
known_models: &HashSet<String>,
external_tables: &HashSet<String>,
) -> (Vec<String>, Vec<String>) {
// Rebuilds this map for EVERY model
let known_models_map: HashMap<String, &String> =
known_models.iter().map(|s| (s.to_lowercase(), s)).collect();
// ...
}
// GOOD — build once, pass in
struct DependencyResolver {
known_lower: HashMap<String, String>, // lowercase → original
external_lower: HashSet<String>,
}
impl DependencyResolver {
fn new(known_models: &HashSet<String>, external: &HashSet<String>) -> Self {
Self {
known_lower: known_models.iter()
.map(|s| (s.to_lowercase(), s.clone()))
.collect(),
external_lower: external.iter()
.map(|s| s.to_lowercase())
.collect(),
}
}
fn categorize(&self, deps: HashSet<String>) -> (Vec<String>, Vec<String>) {
// Reuses pre-built maps — zero per-model allocation
}
}
FF-5: Avoid Newtype-to-String Round-Trips
ModelName, TableName, and FunctionName implement Borrow<str> and AsRef<str>, but many call sites convert to String for HashMap lookups.
// BAD — allocates a String just to look up in a HashMap
let known_models: HashSet<String> = project.models.keys()
.map(|k| k.to_string()) // N allocations
.collect();
// GOOD — use the newtype directly (it impls Borrow<str>)
let known_models: HashSet<&str> = project.models.keys()
.map(|k| k.as_str()) // zero allocations
.collect();
// GOOD — or keep HashMap<ModelName, V> and look up with .get(name.as_str())
// since ModelName: Borrow<str>, HashMap::get accepts &str directly
FF-6: with_capacity in Schema Operations
RelSchema::merge(), with_nullability(), with_source_table() all create fresh Vecs without capacity hints, despite knowing the exact output size.
// BAD — grows by doubling
fn merge(left: &RelSchema, right: &RelSchema) -> Self {
let mut columns = left.columns.clone();
columns.extend(right.columns.iter().cloned());
Self { columns }
}
// GOOD — single allocation, exact size
fn merge(left: &RelSchema, right: &RelSchema) -> Self {
let mut columns = Vec::with_capacity(left.columns.len() + right.columns.len());
columns.extend(left.columns.iter().cloned());
columns.extend(right.columns.iter().cloned());
Self { columns }
}
Code Review Checklist
When reviewing Rust code for memory efficiency, check:
General patterns:
- No
Vec::new()in hot paths when the size is known — usewith_capacity - No
Stringclones where&strorCow<str>suffices - No
clone()to appease the borrow checker — restructure ownership or use references - No
Stringfor fixed vocabularies — enums,&'static str, or interning - No
Vec<Vec<T>>for read-mostly data — flatten with offset arrays - Struct field ordering — largest alignment first to minimize padding
- Large enum variants are
Boxed — small variants don't pay the size tax - Early drops — large allocations scoped or
drop()ed when no longer needed - Zero-copy parsing where input outlives parsed data —
&'a stroverString Box<[T]>for sealed collections —Vecwastes capacity bytes- Stack allocation for small bounded data —
SmallVec,ArrayString - Allocator choice documented if non-default —
jemalloc/mimallocwith benchmarks
Feather-Flow-specific:
- No
RelSchema.clone()whereArc<RelSchema>sharing works — especially in catalog/propagation code - Function registries built once — not per-model in the topological loop
- Arrow schema conversions cached —
rel_schema_to_arrow()not called repeatedly for the same table - Lookup maps built once — no per-model
HashMapconstruction fromknown_models - Newtypes used as-is — no
.to_string()onModelName/TableNamefor HashMap lookups whenBorrow<str>suffices with_capacityon schema operations —merge(),with_nullability(), diagnostic Vecs
For detailed examples and advanced patterns, see examples.md.
Verification
After every unit of work, run make ci before moving on. This ensures format, clippy, tests, and docs all pass. Do not proceed to the next task until CI is green.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?