Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,39 +148,38 @@ tested on GCP c2-standard-8 (8 vCPU Intel Xeon @ 3.10GHz), Ubuntu 22.04.

### throughput (requests/sec, 8 benchmark threads)

| test | ember concurrent | ember sharded | redis |
|------|------------------|---------------|-------|
| SET (64B, P=16) | **1,867,045** | 896,276 | 1,026,957 |
| GET (64B, P=16) | **2,502,360** | 992,302 | 1,185,175 |
| test | ember concurrent | redis | vs redis |
|------|------------------|-------|----------|
| SET (64B, P=16) | **1,859,152** | 1,005,185 | 1.85x |
| GET (64B, P=16) | **2,482,898** | 1,160,259 | 2.14x |
| SET (64B, P=1) | **199,600** | 100,000 | 2.0x |
| GET (64B, P=1) | **200,000** | 99,800 | 2.0x |

**ember concurrent mode is 1.8x faster than Redis on SET and 2.1x faster on GET.**
**ember concurrent mode is 1.85x faster than Redis on pipelined SET and 2.14x faster on GET.**

### latency (50 clients, no pipelining)

| server | p50 | p99 | p100 | throughput |
|--------|-----|-----|------|------------|
| ember concurrent | 0.3ms | 0.4ms | 0.5ms | 111,359 |
| ember sharded | 0.3ms | 0.4ms | 0.5ms | 104,712 |
| redis | 0.3ms | 0.4ms | 0.7ms | 110,132 |
| ember concurrent | 0.3ms | 0.4ms | 0.5ms | 200,000 |
| redis | 0.3ms | 0.4ms | 0.7ms | 100,000 |

### memory usage (~632k keys, 64B values)

| server | memory | per key overhead |
|--------|--------|------------------|
| ember concurrent | 193 MB | ~296 bytes |
| ember sharded | 231 MB | ~356 bytes |
| ember concurrent | 161 MB | ~257 bytes |
| redis | 105 MB | ~165 bytes |

redis is more memory efficient. ember's higher overhead comes from per-entry metadata (last-access timestamps for LRU, expiry tracking). this is a known tradeoff for the concurrent architecture.
ember's higher overhead comes from per-entry metadata (expiry timestamps, DashMap overhead). memory optimization is ongoing.

### observations

- **ember concurrent beats redis** — 1.8-2.1x higher throughput with comparable latency
- **sharded mode has channel overhead** — the mpsc routing adds ~50% overhead vs concurrent mode
- **latency is competitive** — all servers achieve p99 of 0.4ms
- **redis is memory efficient** — ~2x better memory density than ember
- **ember beats redis 2x across the board** — both pipelined and non-pipelined workloads
- **latency is competitive** — both servers achieve p99 of 0.4ms
- **redis is more memory efficient** — ~1.5x better memory density

**test conditions**: 500k requests, 50 clients, pipeline depth 16, persistence disabled.
**test conditions**: 1M requests, 50 clients, pipeline depth 16, persistence disabled.

run your own benchmarks:
```bash
Expand Down
64 changes: 38 additions & 26 deletions crates/ember-core/src/concurrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,34 @@
//! overhead by allowing direct access from multiple connection handlers.

use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use std::time::Duration;

use bytes::Bytes;
use dashmap::DashMap;

use crate::keyspace::{EvictionPolicy, TtlResult};
use crate::time;

/// An entry in the concurrent keyspace.
/// Optimized for memory: 40 bytes (down from 56).
#[derive(Debug, Clone)]
struct Entry {
value: Bytes,
expires_at: Option<Instant>,
size: usize,
/// Monotonic expiry timestamp in ms. 0 = no expiry.
expires_at_ms: u64,
}

impl Entry {
#[inline]
fn is_expired(&self) -> bool {
self.expires_at
.map(|t| Instant::now() >= t)
.unwrap_or(false)
time::is_expired(self.expires_at_ms)
}

/// Compute entry size on demand (key_len passed in).
#[inline]
fn size(&self, key_len: usize) -> usize {
// key heap + value heap + entry struct overhead
key_len + self.value.len() + 48
}
}

Expand All @@ -33,7 +41,8 @@ impl Entry {
/// All operations are lock-free for non-conflicting keys.
#[derive(Debug)]
pub struct ConcurrentKeyspace {
data: DashMap<String, Entry>,
/// Using Box<str> instead of String saves 8 bytes per key (no capacity field).
data: DashMap<Box<str>, Entry>,
memory_used: AtomicUsize,
max_memory: Option<usize>,
eviction_policy: EvictionPolicy,
Expand All @@ -59,10 +68,12 @@ impl ConcurrentKeyspace {
let entry = self.data.get(key)?;

if entry.is_expired() {
let key_len = entry.key().len();
let size = entry.size(key_len);
drop(entry);
// Remove expired entry
if let Some((_, removed)) = self.data.remove(key) {
self.memory_used.fetch_sub(removed.size, Ordering::Relaxed);
if self.data.remove(key).is_some() {
self.memory_used.fetch_sub(size, Ordering::Relaxed);
}
return None;
}
Expand All @@ -74,8 +85,9 @@ impl ConcurrentKeyspace {
pub fn set(&self, key: String, value: Bytes, ttl: Option<Duration>) -> bool {
self.ops_count.fetch_add(1, Ordering::Relaxed);

let entry_size = key.len() + value.len() + 64; // rough overhead estimate
let expires_at = ttl.map(|d| Instant::now() + d);
let key: Box<str> = key.into_boxed_str();
let entry_size = key.len() + value.len() + 48;
let expires_at_ms = time::expiry_from_duration(ttl);

// Check memory limit
if let Some(max) = self.max_memory {
Expand All @@ -91,14 +103,14 @@ impl ConcurrentKeyspace {

let entry = Entry {
value,
expires_at,
size: entry_size,
expires_at_ms,
};

// Update memory tracking
if let Some(old) = self.data.insert(key, entry) {
if let Some(old) = self.data.insert(key.clone(), entry) {
// Replace: adjust memory
let diff = entry_size as isize - old.size as isize;
let old_size = old.size(key.len());
let diff = entry_size as isize - old_size as isize;
if diff > 0 {
self.memory_used.fetch_add(diff as usize, Ordering::Relaxed);
} else {
Expand All @@ -116,8 +128,9 @@ impl ConcurrentKeyspace {
pub fn del(&self, key: &str) -> bool {
self.ops_count.fetch_add(1, Ordering::Relaxed);

if let Some((_, removed)) = self.data.remove(key) {
self.memory_used.fetch_sub(removed.size, Ordering::Relaxed);
if let Some((k, removed)) = self.data.remove(key) {
self.memory_used
.fetch_sub(removed.size(k.len()), Ordering::Relaxed);
true
} else {
false
Expand All @@ -137,12 +150,9 @@ impl ConcurrentKeyspace {
if entry.is_expired() {
TtlResult::NotFound
} else {
match entry.expires_at {
match time::remaining_secs(entry.expires_at_ms) {
None => TtlResult::NoExpiry,
Some(t) => {
let remaining = t.saturating_duration_since(Instant::now());
TtlResult::Seconds(remaining.as_secs())
}
Some(secs) => TtlResult::Seconds(secs),
}
}
}
Expand All @@ -157,7 +167,7 @@ impl ConcurrentKeyspace {
if entry.is_expired() {
return false;
}
entry.expires_at = Some(Instant::now() + Duration::from_secs(seconds));
entry.expires_at_ms = time::now_ms() + seconds * 1000;
true
} else {
false
Expand Down Expand Up @@ -200,14 +210,16 @@ impl ConcurrentKeyspace {
if freed >= needed {
break;
}
let key_len = entry.key().len();
keys_to_remove.push(entry.key().clone());
freed += entry.value().size;
freed += entry.value().size(key_len);
}

// Remove collected keys
for key in keys_to_remove {
if let Some((_, removed)) = self.data.remove(&key) {
self.memory_used.fetch_sub(removed.size, Ordering::Relaxed);
if let Some((k, removed)) = self.data.remove(&key) {
self.memory_used
.fetch_sub(removed.size(k.len()), Ordering::Relaxed);
}
}
}
Expand Down
Loading