emilk / emilk/egui

Idea: example showing automatic memoization for expensive per-frame computations

Open
#8,151 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
30.6k
Forks
2.1k
Avg merge
1d 12h
Merged PRs (30d)
67

Description

## Motivation

egui's immediate-mode model is simple and productive — but it means every
frame recomputes everything. When a frame includes a heavy pipeline
(filter → aggregate → format on 500K records), the naive approach takes
~12 ms/frame at 60 fps (budget: 16.67 ms). Add a few more widgets and
you drop frames.

The standard fix is hand-rolled caching with version checks:

```rust
// Stage 1: filter
let filter_changed = params != cached_filter_params;
if filter_changed {
cached_filtered = Some(filter_data(&data, ¶ms));
cached_filter_params = params.clone();
}
// Stage 2: aggregate — depends on filter output
let agg_changed = filter_changed || mode != cached_agg_mode;
if agg_changed {
cached_agg = Some(aggregate(&cached_filtered, mode));
cached_agg_mode = mode;
}
// Stage 3: format — depends on aggregate
let fmt_changed = agg_changed || mode != cached_fmt_mode;
if fmt_changed {
cached_formatted = Some(format_results(&cached_agg, mode));
cached_fmt_mode = mode;
}
```

This works, but:
- Each new pipeline stage requires updating 2–3 condition checks
- Forgetting a cascade edge silently produces stale output
- With N stages, you maintain O(N²) invalidation paths

## The alternative: automatic dependency tracking

Using a reactive layer, the same 3-stage pipeline becomes:

```rust
let filtered = Memo::new(|| filter_data(&raw.read(), ¶ms.read()));
let aggregated = Memo::new(|| aggregate(&filtered.read(), mode.read()));
let formatted = Memo::new(|| format_results(&aggregated.read(), mode.read()));
```

Each `Memo` tracks which signals it read during its last computation.
When a signal changes, only the memos that actually depend on it are
marked dirty — and only those get recomputed on the next read. Clean
reads are a single version-number comparison (0.00 ms).

## Concrete demo

I have a working egui app that compares both approaches side by side
with a real 500K-record pipeline:

![screenshot](https://github.com/user-attachments/assets/9645df18-172a-4ee2-8ea3-d18815703b41)

- **Left panel**: manual version-check cascade (~20 lines of cache logic)
- **Right panel**: Signal + Memo chain (3 × `Memo::new`, zero invalidation code)
- Both produce identical output

Repo: https://github.com/chh-itt/auralis — the egui demo lives under
`demos/egui-demo/`.

## Performance (500K records, 1000 frames, release build)

| Scenario (change rate) | manual cache | memo chain | overhead |
|---|---|---|---|
| 1% (mostly idle) | 0.12 ms/fr | 0.14 ms/fr | +13.7% |
| 10% | 1.19 ms/fr | 1.47 ms/fr | +23.8% |
| 50% (churning) | 5.96 ms/fr | 7.50 ms/fr | +26.0% |
| **3000-frame total** | **7,261 ms** | **9,106 ms** | **+25.4%** |

Single-access cache hit (inputs unchanged): **0.00 ms** on both sides.
Signal set throughput: **~837,000 sets/ms** (~1.2 ns/set).

The memo chain carries ~25% overhead relative to hand-written cache,
but the absolute difference in idle frames is 0.02 ms — imperceptible.
The tradeoff is 3 lines vs 20 lines of error-prone invalidation logic.

## On the dependency

I know adding an external dependency to `examples/` is not a light
decision. A few points in case it helps:

- `auralis-signal` has **zero dependencies** of its own and is
`#![forbid(unsafe_code)]`. It is a single-purpose crate: ~800 lines
of signal/memo/batch logic, nothing else.
- I'm not tied to auralis specifically. If there's a preferred pattern
or an existing crate you'd rather see used, I'm happy to adapt.
- Alternatively, if you'd prefer the example to be fully self-contained
(no third-party deps), I can embed a simplified memo helper inline
— the core idea (version-checked lazy recompute) is ~50 lines and
the educational value is the same.

## What I'd like to contribute

A single-file example under `examples/`, roughly:

- ~150 lines, `eframe` + one dependency (or zero, depending on your
preference above)
- A small but realistic pipeline (generate → filter → aggregate → table)
where caching visibly matters
- Self-contained: one `cargo run` and it works

I'm also open to putting it under `egui_demo_app/` as a new tab if that
fits better — or anywhere else you think makes sense.

Happy to adjust scope, tone, or location. Let me know if this is
something you'd consider, and what shape would work best for the project.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.