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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ A validation release, not a feature release: it exists to prove the correctness
- Still not covered, deliberately: `riscv32imc` (ESP32-C3). `qemu-system-riscv32 -machine virt` has the A extension, so emulating it would test a target that does not need the feature. And nothing has run on physical silicon
- `qemu-test/` is its own workspace with its own `.cargo/config.toml` runner, the same isolation `fuzz/` uses, so it never affects a host build of `pulse_map`

**AccessBuffer drain**
- `ConcurrentPulseMap::get()` buffers its LRU/LFU priority updates in the `AccessBuffer` (introduced v0.6.2), but nothing ever drained the buffer — so reads never fed the eviction policy. Its hit rate sat 0.92 points under `TypedPulseMap`'s: **94.456% vs 95.372%** in the `hitrate_16384` benchmark
- `insert()` now drains the buffer under the target bucket's lock, so read latency is untouched: the drain runs on the write path only
- Ships with a `BucketGuard` fix so the drain is Miri-clean
- Result: `ConcurrentPulseMap::get()` ties `TypedPulseMap` at **95.372%**

**Embedded footprint evidence — PR #22**
**Embedded footprint evidence**
- Answers the two objections an embedded reviewer would actually raise, with measurements instead of prose. Both run in the existing QEMU CI job; no new infrastructure
- **`alloc` is needed only at construction, and that is now machine-checked.** The QEMU test's bump allocator counts its calls: `TypedPulseMap<u32, u32>` makes **2 allocations at `new()` and 0 across 256 `insert`+`get`+`remove`**. The two are `buckets` and `slots_ttl`, so a fixed `buckets × 128`-byte arena is sufficient for the map's whole lifetime. This is the difference between "needs a heap" (a dealbreaker in firmware that deliberately has none) and "needs a static arena at init" (a line in `main`)
- The counterexample is reported too, not hidden: `TypedPulseMap<u64, u64>` exceeds the 6-byte key / 7-byte value inline window, so entries reach the slab and allocate — 14 allocations across 6 inserts. A static arena is not enough for that path
Expand Down
88 changes: 44 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ The closest comparison in the Rust ecosystem is the [`lru`](https://crates.io/cr
|---------|---------------------|----------|
| Two structures to manage | HashMap + linked list | Single structure |
| Eviction needs extra fetches | 2–3 pointer chases per eviction | Metadata is in the same bucket |
| Memory per entry (measured, 1M capacity, RSS) | 67.7B (`lru` crate) | **25.6B** |
| Memory per entry (measured, 1M capacity, RSS) | 67.7B (`lru` crate) | **34.2B** |
| Cache alignment | Random pointer chasing | 64-byte aligned bucket |

> Slot payload itself is 14 bytes — the 25.6B/entry above is the real
> Slot payload itself is 14 bytes — the 34.2B/entry above is the real
> measured cost including bucket/hash-table overhead at scale. See
> [Memory Footprint](#memory-footprint-measured) for full numbers and methodology.

Expand Down Expand Up @@ -239,27 +239,27 @@ into a full cache, forcing continuous eviction under real contention.

| Cache | p50 | p99 (mean ± stddev) | max (mean ± stddev, high variance — reference only) |
|---|:-:|:-:|:-:|
| **PulseMap** | **323ns** | **911ns ± 46ns** | 5.85ms ± 1.69ms |
| QuickCache | 350ns | 1.437µs ± 54ns | 2.03ms ± 1.26ms |
| Simple (`Mutex<HashMap>`) | 564ns | 29.10µs ± 4.58µs | 22.52ms ± 4.54ms |
| LRU (`Mutex<LruCache>`) | 2.415µs | 42.25µs ± 2.32µs | 4.43ms ± 1.13ms |
| Moka | 902ns | 389.57µs ± 13.53µs | 13.34ms ± 2.04ms |
| **PulseMap** | **306ns** | **853ns ± 27ns** | 6.681ms ± 2.902ms |
| QuickCache | 282ns | 1.005µs ± 126ns | 17.727ms ± 7.592ms |
| Simple (`Mutex<HashMap>`) | 530ns | 39.856µs ± 4.877µs | 31.701ms ± 7.746ms |
| LRU (`Mutex<LruCache>`) | 772ns | 36.564µs ± 21.172µs | 20.164ms ± 10.088ms |
| Moka | 997ns | 481.318µs ± 31.843µs | 22.540ms ± 5.045ms |

![p99 write-pressure benchmark chart](./docs/images/write_pressure_p99_benchmark.png)

**Head-to-head verdicts** (is the gap bigger than the trial-to-trial noise, or just a fluke?):

| Comparison | p99 gap | Combined stddev | Verdict |
|---|:-:|:-:|---|
| PulseMap vs Moka | 388.66µs | 13.58µs | PulseMap reliably lower — **427.6x** |
| PulseMap vs LRU | 41.34µs | 2.36µs | PulseMap reliably lower — **46.4x** |
| PulseMap vs Simple | 28.19µs | 4.63µs | PulseMap reliably lower — **31.9x** |
| PulseMap vs QuickCache | 526ns | 99ns | PulseMap reliably lower — **1.6x** (real, but the closest margin of the four) |
| PulseMap vs Moka | 480.465µs | 31.870µs | PulseMap reliably lower — **~564x** |
| PulseMap vs LRU | 35.712µs | 21.198µs | PulseMap reliably lower — **~42.9x** |
| PulseMap vs Simple | 39.003µs | 4.904µs | PulseMap reliably lower — **~46.7x** |
| PulseMap vs QuickCache | 153ns | 152ns | PulseMap reliably lower — **~1.2x** (margin is real but thin) |

**Honest read of these numbers:**
- Against Moka, the gap is enormous and not close — this is where a background-eviction-thread design under queue backpressure really costs you.
- Against a naive `Mutex<HashMap>` and a `Mutex`-wrapped `lru::LruCache`, PulseMap wins by a wide margin because both serialize all writers behind one lock; PulseMap and QuickCache don't.
- Against **QuickCache** — also a lock-free, inline-eviction design — the margin is real (5.3x the combined noise) but modest at 1.6x. Both designs are in the same tier; treat this as "PulseMap is consistently a bit faster here," not "QuickCache is a bad cache."
- Against **QuickCache** — also a lock-free, inline-eviction design — the margin is real but thin (gap is 1.0x the noise) at ~1.2x. Both designs are in the same tier; treat this as "PulseMap is consistently a bit faster here," not "QuickCache is a bad cache."
- `max` numbers have high stddev across *every* cache tested (a single unlucky scheduler preemption can hit anyone), which is why p99 — not max — is the metric to trust for comparing tail latency.

Full benchmark source (multi-threaded, statistical harness) is in `examples/`.
Expand All @@ -276,26 +276,26 @@ never written to won't show up in RSS even if it was "reserved."

| Cache | Capacity | Empty RSS | Filled RSS | Bytes/entry |
|---|:-:|:-:|:-:|:-:|
| **PulseMap** | 100K | 3.16MB | 3.12MB | 32.7B |
| **PulseMap** | 500K | 12.30MB | 12.30MB | 25.8B |
| **PulseMap** | 1M | 24.42MB | 24.41MB | **25.6B** |
| QuickCache | 100K | 0.22MB | 3.90MB | 40.9B |
| QuickCache | 500K | 0.22MB | 17.83MB | 37.4B |
| QuickCache | 1M | 0.22MB | 34.35MB | 36.0B |
| LRU (`lru` crate) | 100K | 0.25MB | 5.28MB | 55.4B |
| LRU (`lru` crate) | 500K | 1.14MB | 32.37MB | 67.9B |
| LRU (`lru` crate) | 1M | 2.12MB | 64.55MB | 67.7B |
| Moka | 100K | 2.29MB | 29.79MB | 312.3B |
| Moka | 500K | 8.53MB | 145.88MB | 305.9B |
| Moka | 1M | 16.53MB | 291.12MB | 305.3B |
| **PulseMap** | 100K | 4.36MB | 4.43MB | 46.4B |
| **PulseMap** | 500K | 16.51MB | 16.51MB | 34.6B |
| **PulseMap** | 1M | 32.62MB | 32.63MB | **34.2B** |
| QuickCache | 100K | 0.12MB | 3.85MB | 40.4B |
| QuickCache | 500K | 0.14MB | 17.80MB | 37.3B |
| QuickCache | 1M | 0.12MB | 33.86MB | 35.5B |
| LRU (`lru` crate) | 100K | 0.21MB | 5.25MB | 55.0B |
| LRU (`lru` crate) | 500K | 1.07MB | 32.33MB | 67.8B |
| LRU (`lru` crate) | 1M | 2.01MB | 64.59MB | 67.7B |
| Moka | 100K | 2.17MB | 29.71MB | 311.5B |
| Moka | 500K | 8.48MB | 145.76MB | 305.7B |
| Moka | 1M | 16.42MB | 291.09MB | 305.2B |
| `std::HashMap` (no eviction, reference only) | 1M | 2.20MB | 18.11MB | 19.0B |

**What this shows:**
- At 1M capacity, PulseMap uses **29% less memory per entry than QuickCache**, **62% less than `lru`**, and **91% less than Moka**.
- PulseMap's empty and filled RSS are nearly identical (24.42MB → 24.41MB) — memory is committed at `new()` and stays flat. Every other cache tested grows lazily as you insert. If predictable, front-loaded memory is a requirement (embedded, containers with tight memory limits), this is the practically relevant number, not just the average bytes/entry.
- At 1M capacity, PulseMap uses **~4% less memory per entry than QuickCache**, **~50% less than `lru`**, and **~89% less than Moka**.
- PulseMap's empty and filled RSS are nearly identical (32.62MB → 32.63MB) — memory is committed at `new()` and stays flat. Every other cache tested grows lazily as you insert. If predictable, front-loaded memory is a requirement (embedded, containers with tight memory limits), this is the practically relevant number, not just the average bytes/entry.
- `std::HashMap`'s 19.0B/entry is lower than PulseMap's, but it's not a fair comparison — it has no eviction, no fixed capacity, and no priority tracking; it's included only as a reference point for "what raw storage with none of PulseMap's features would cost."

> **Note:** v0.6.2 maintains the same memory efficiency as these measurements.
> **Note:** v0.6.5 maintains the same memory efficiency as these measurements.

---

Expand Down Expand Up @@ -361,23 +361,23 @@ trending API route).

| Scenario | Winner | Notes |
|---|---|---|
| A — Realistic mixed workload (hot-key 80/20) | QuickCache | QuickCache is still faster at 423ns p99 (PulseMap GET p99 is now 964ns); both hit similar ~91-92% cache hit rates |
| B — Large-scale sustained inserts | QuickCache | QuickCache at 7.69M ops/s nearly matched by PulseMap at 7.47M ops/s; Moka is ~20x slower here |
| C — Extreme hot-key contention (64 keys, 8 threads) | **PulseMap** | PulseMap is ~1.9x faster (1.134µs p99 vs QuickCache's 2.158µs), and far more *consistent* — QuickCache's stddev was 20x higher, meaning its tail latency got unpredictable under contention while PulseMap's didn't |
| A — Realistic mixed workload (hot-key 80/20) | QuickCache | QuickCache is faster at ~425ns GET p99 (PulseMap GET p99 is ~860ns); hit rates are close (91.7% vs 91.2%) |
| B — Large-scale sustained inserts | QuickCache | QuickCache at 9.2–10.0M ops/s vs PulseMap at 8.7–8.8M ops/s in the two most recent runs; an earlier session had PulseMap ahead (6.55M vs 5.88M), so this one sits inside run-to-run variance; Moka is much slower here (~0.4M ops/s) |
| C — Extreme hot-key contention (64 keys, 8 threads) | Split | QuickCache finishes first (~55–75ms vs ~82–119ms), but PulseMap's p99 tail is ~2x lower (1.33µs vs 2.79µs) — three runs agree |
| D — Eviction quality (hit rate under memory pressure) | **PulseMap** | Highest hit rate of all 4 caches (96.73%), consistent across every read/write ratio tested — see [Eviction Quality](#eviction-quality-hit-rate-not-speed) |
| Memory footprint at scale | **PulseMap** | 29% less per-entry memory than QuickCache, with flat (non-growing) allocation |

**Practical read:** for general-purpose low-contention caching, QuickCache
is a strong, slightly faster choice. **PulseMap's specific advantage shows
up under contention** — many threads repeatedly touching a small, hot set
of keys — and in memory-constrained environments where a flat, predictable
allocation matters more than a small latency edge. Rate limiters on popular
IPs, hot session keys, and trending-content caches are the workloads where
PulseMap pulls ahead; generic low-contention application caching is closer
to a coin flip between PulseMap and QuickCache. On top of the latency picture, PulseMap's eviction policy also kept the
right keys hot more often than every alternative tested — so even in the
low-contention case where QuickCache is a bit faster, PulseMap's cache
hit rate was still the highest of the four.
| Memory footprint at scale | **PulseMap** | ~4% less per-entry memory than QuickCache, with flat (non-growing) allocation |

**Practical read:** on raw throughput, QuickCache wins everywhere — mixed
workloads, sustained inserts, and total time under contention. **PulseMap's
advantage is the tail, not the mean**: under extreme hot-key contention its
p99 is ~2x lower than QuickCache's (1.33µs vs 2.79µs), and in the
latency-spikes benchmark its p99 is 987ns vs QuickCache's 1.516µs — when a
viral user or trending route makes every request queue, that tail is the
user-visible latency. PulseMap also keeps the highest cache hit rate of
everything tested (96.73%, consistent across read/write ratios) and its
allocation is flat and bounded. Rate limiters on popular IPs, hot session
keys, and latency-sensitive caches are where PulseMap fits; for
throughput-first general-purpose caching, QuickCache is the honest pick.

---

Expand Down Expand Up @@ -696,7 +696,7 @@ pulse_map_free(map);

## Known Limitations

- Lookup is ~2.3x slower than quick_cache on mixed workloads — serialization trade-off for `no_std`/FFI
- Lookup is ~2x slower than quick_cache on mixed workloads — serialization trade-off for `no_std`/FFI
- Lock-free reads via AtomicU64 MetaWord (v0.6.2+) — reads no longer acquire bucket spinlocks
- On low-contention, general-purpose read/write mixed workloads, QuickCache is modestly faster (see [Where PulseMap Fits](#where-pulsemap-fits)) — PulseMap's edge is specifically under contention and in memory footprint, not universal
- **No performance figure has ever been measured on a microcontroller.** Every number in this README is x86_64. The QEMU job proves the code *runs* on Cortex-M0, but QEMU is not cycle-accurate — no pipeline model, no flash wait states, no bus contention — so it cannot honestly produce a timing figure. Treat "fast on embedded" as unproven until someone measures it on real silicon (an RP2040 is the natural part: Cortex-M0+, and it actually needs `critical-section`)
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- [Architecture & Internals](./architecture.md)
- [Performance & Benchmarks](./benchmarks.md)
- [Feature Flags](./features.md)
- [Embedded & `no_std`](./embedded-no-std.md)
- [FFI — C Bindings](./ffi-bindings.md)

---
Expand Down
10 changes: 5 additions & 5 deletions docs/src/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ HashMap has no eviction — it's a different category entirely:

## Memory Efficiency

| Map Size | PulseMap | HashMap | Savings |
| Map Size | PulseMap | `lru` | Savings |
|:--------:|:-------:|:-------:|:-------:|
| 1K entries | 16 KB | 48 KB | **67%** |
| 10K entries | 160 KB | 480 KB | **67%** |
| 100K entries | 1.6 MB | 4.8 MB | **67%** |
| 1M entries | 16 MB | 48 MB | **67%** |
| 1K entries | ~34 KB | ~68 KB | **50%** |
| 10K entries | ~342 KB | ~678 KB | **50%** |
| 100K entries | ~3.4 MB | ~6.8 MB | **50%** |
| 1M entries | ~34.2 MB | ~67.7 MB | **50%** |

## Running Benchmarks

Expand Down
10 changes: 10 additions & 0 deletions docs/src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

See the full [CHANGELOG.md](https://github.com/ddsha441981/pulse_map/blob/main/CHANGELOG.md) in the repository root.

## Unreleased (v0.6.5)

### 🧠 AccessBuffer Drain + Validation & Embedded Proofs

- **AccessBuffer drain**: deferred LRU/LFU updates buffered by `get()` are now drained on the write path. `ConcurrentPulseMap::get()` ties `TypedPulseMap` at **95.372%** hit rate (peek-only control: 94.456% — counting reads is worth +0.92 points). Read latency is untouched: the drain runs inside `insert()`. Ships with a `BucketGuard` fix so the drain is Miri-clean.
- **Validation**: cargo-fuzz shadow-map oracle (**5.46M executions clean** under AddressSanitizer), Miri in CI for both engine and sync configs, loom models for the `MetaWord` CAS and `AccessBuffer`, multi-threaded soak at **+0.0 MB RSS drift** (147.9M ops).
- **Embedded**: 8-target `cargo check` matrix in CI — `thumbv6m` (Cortex-M0) and `riscv32imc` (ESP32-C3) now compile via the opt-in `critical-section` feature. QEMU-executed tests on Cortex-M3 and Cortex-M0: 12 assertions, heap measured at **128 B per bucket** (2 KiB for a 16-bucket map).

---

## v0.6.4 (2026-08-19)

### 🌍 Portable AtomicU64 — Cross-Platform Compatibility
Expand Down
115 changes: 115 additions & 0 deletions docs/src/embedded-no-std.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Embedded & `no_std`

PulseMap runs on bare-metal microcontrollers — not just `cargo check`, but
actually executed on emulated ARM hardware via QEMU.

## Quick Start

```toml
# Most targets (Cortex-M3+, RISC-V with A extension, WASM)
pulse_map = { version = "0.6", default-features = false }

# Cortex-M0 / RP2040 / ESP32-C3 (no hardware atomic CAS)
pulse_map = { version = "0.6", default-features = false, features = ["critical-section"] }
```

## What You Get in `no_std`

With `default-features = false`, PulseMap needs only `alloc` (a heap allocator).
You get:

- `PulseMapRaw` — insert / get / remove / peek / TTL
- `TypedPulseMap<K, V>` — type-safe wrapper
- `MetaWord` — 8-byte packed LFU+LRU metadata
- `Bucket` — 64-byte cache-line-aligned storage

You do **not** get `ConcurrentPulseMap` or `ShardedPulseMap` (they need `std`
for `RwLock` and threading).

## Verified Targets

Every push runs `cargo check` in CI for all 8 targets. Two of them also
**execute** on QEMU (marked with 🏃):

| Target | Chips | Extra Feature | Tested |
|---|---|---|---|
| `thumbv7m-none-eabi` | Cortex-M3 | — | 🏃 QEMU |
| `thumbv7em-none-eabihf` | Cortex-M4F / M7F (STM32F4) | — | ✅ build |
| `thumbv8m.main-none-eabi` | Cortex-M33 (nRF9160) | — | ✅ build |
| `thumbv6m-none-eabi` | Cortex-M0 / M0+ (RP2040) | `critical-section` | 🏃 QEMU |
| `riscv32imac-unknown-none-elf` | RISC-V with A extension | — | ✅ build |
| `riscv32imc-unknown-none-elf` | ESP32-C3 | `critical-section` | ✅ build |
| `aarch64-unknown-none` | 64-bit bare metal | — | ✅ build |
| `wasm32-unknown-unknown` | WebAssembly | — | ✅ build |

## Why `critical-section`?

PulseMap's `MetaWord` is an `AtomicU64`. On chips without hardware atomic CAS
(Cortex-M0, RISC-V without the A extension), `portable-atomic` cannot build a
64-bit atomic at all. The `critical-section` feature solves this by masking
interrupts around the update.

The critical-section **implementation** comes from your HAL, not from PulseMap:

```rust
// Cortex-M0 / RP2040 — add to your binary crate
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }

// ESP32-C3
esp-hal = { version = "...", features = ["critical-section"] }
```

## Memory Footprint

On a Cortex-M with 16 KiB RAM, PulseMap fits comfortably:

| Config | Buckets | Slots | Heap Used |
|---|---|---|---|
| 16 buckets | 16 | 64 | **2,048 bytes** |
| 4 buckets | 4 | 16 | **512 bytes** |

Each bucket is exactly 128 bytes of heap (64B bucket + 64B slot TTL metadata).
A 64-slot cache in 2 KiB of RAM is what makes PulseMap practical on
parts where Moka and QuickCache cannot even compile.

## Running the QEMU Tests Yourself

```bash
cd qemu-test

# Cortex-M3 (portable-atomic spinlock fallback)
cargo run --release --target thumbv7m-none-eabi

# Cortex-M0 (critical-section, interrupt masking)
cargo run --release --target thumbv6m-none-eabi --features m0
```

Requires `qemu-system-arm` installed (`apt install qemu-system-arm`).

The test inserts, gets, evicts, checks TTL, and removes — all on real ARM
instructions, not a compile check. Output:

```
qemu-test: 16 buckets, 64 nominal slots, 3072 heap bytes used
qemu-test: all checks passed
```

The 3,072 covers **all three maps the test creates** — the 16-bucket main
map (2,048 B) plus two 4-bucket maps for the TTL and remove checks
(512 B each): 24 buckets × 128 B. A lone 16-bucket map is 2 KiB, matching
the table above.

## PulseMap vs LRU on Embedded

In `no_std`, the only other option is `lru`. Here's why PulseMap wins:

| | PulseMap | lru |
|---|---|---|
| Memory per entry | **34 B** (at scale) | 68 B |
| Eviction policy | LFU + LRU (smart) | Pure LRU |
| Cache-line aligned | ✅ 64-byte buckets | ❌ pointer chasing |
| Hit rate (Zipfian) | **96.73%** | 95.83% |
| Allocation pattern | Upfront, predictable | Per-insert, fragmented |

On memory-constrained MCUs, PulseMap stores **2x more entries** in the same
RAM, with a better eviction policy.
4 changes: 2 additions & 2 deletions docs/src/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ moka's strength is its W-TinyLFU eviction policy (better hit rates on skewed wor
### How much memory does PulseMap use?

```
Memory = num_buckets × 64 bytes + slab_overhead
Memory ≈ capacity × 34.2 bytes + slab_overhead
```

For inline-only workloads (small KV pairs): exactly `num_buckets × 64` bytes.
For inline-only workloads (small KV pairs): ~34.2 bytes per entry at scale.

### Can I use PulseMap in no_std?

Expand Down
Loading
Loading