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
49 changes: 36 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Build
cargo build

# Build without proc-macro feature
cargo build -p devirt --no-default-features

# Run all tests (includes UI/compile-fail tests via trybuild)
cargo test --workspace --exclude devirt-fuzz

# Run only UI/compile-fail tests
# Run declarative-macro-only tests
cargo test -p devirt --no-default-features

# Run only UI/compile-fail tests (declarative macro)
cargo test --test ui -p devirt

# Run only UI/compile-fail tests (proc-macro attribute)
cargo test --test ui_attr -p devirt

# Run Kani bounded model checker (requires cargo-kani)
cargo kani --tests -p devirt

Expand All @@ -22,7 +31,7 @@ cargo kani --tests -p devirt
# Stacked Borrows
cargo +nightly miri test -p devirt --lib

# Run benchmarks (LTO required for meaningful numbers)
# Run benchmarks
cargo bench

# Run example
Expand All @@ -40,34 +49,46 @@ verus crates/verify/src/lib.rs --crate-type=lib

## Architecture

This is a workspace with three crates:
This is a workspace with four crates:

- **`crates/core`** (`devirt`) — the main proc-macro-free macro library. Two public macros: `r#trait!` and `r#impl!`.
- **`crates/core`** (`devirt`) — the main macro library. Exports `devirt!` (declarative) or `#[devirt]` (proc-macro attribute) depending on the `macros` feature (on by default). Both delegate to the internal `__devirt_define!` macro.
- **`crates/macros`** (`devirt-macros`) — proc-macro crate providing the `#[devirt]` attribute. Optional dependency of `crates/core`, enabled by the `macros` feature.
- **`crates/verify`** (`devirt-verify`) — Verus formal proofs of dispatch correctness.
- **`fuzz`** — libfuzzer differential fuzzing comparing devirt dispatch vs. plain vtable.

### The Vtable-Pointer Comparison Pattern

The core idea: the generated dispatch shim lives in an inherent `impl dyn Trait { ... }` block (not as a trait default method). For each call, it extracts the `[data, vtable]` halves of the fat pointer via `transmute::<&dyn Trait, [usize; 2]>` and compares the vtable half against the compile-time-known vtable for each hot type (obtained by coercing a dangling `*const HotType` to `*const dyn Trait`). On match, the data pointer is reinterpreted as `&HotType` and the concrete type's `__spec_*` method is called directly (fully inlined under LTO). On a full miss, the shim falls through to a single vtable call via the inner trait's `__spec_*` method.
The core idea: the generated dispatch shim lives in an inherent `impl dyn Trait { ... }` block (not as a trait default method). For each call, it extracts the `[data, vtable]` halves of the fat pointer via `transmute::<&dyn Trait, [usize; 2]>` and compares the vtable half against the compile-time-known vtable for each hot type (obtained by coercing a dangling `*const HotType` to `*const dyn Trait`). On match, the data pointer is reinterpreted as `&HotType` and the concrete type's `__spec_*` method is called directly (fully inlined). On a full miss, the shim falls through to a single vtable call via the inner trait's `__spec_*` method.

Why inherent methods on `dyn Trait` and not trait default methods: a default method body cannot cast `self as *const dyn Trait` because `Self: ?Sized` at that point. Inherent impls on `dyn Trait` have `Self = dyn Trait` directly, so the cast is just a ref-to-pointer conversion.

### `r#trait!` Expansion
### Macro Structure

All dispatch expansion logic lives in `__devirt_define!` (`#[doc(hidden)]`, `#[macro_export]`, always available). It has two entry points:

- `__devirt_define! { @trait ... }` — generates the trait, inner trait, dispatch shim, blanket impl
- `__devirt_define! { @impl ... }` — generates `impl __TraitNameImpl for T { __spec_* ... }`

The public APIs delegate to it:

- **`#[devirt::devirt(Hot1, Hot2)]`** (proc-macro attribute, default) — parses the trait/impl and emits `::devirt::__devirt_define!` calls
- **`devirt::devirt!`** (declarative macro, `default-features = false`) — thin dispatcher that forwards to `$crate::__devirt_define!`

### `__devirt_define! { @trait ... }` Expansion

For a trait `Foo` with hot types `[A, B]`:
1. Generates hidden inner trait `__FooImpl` with `__spec_*` method declarations — user types provide the bodies via `r#impl!`.
1. Generates hidden inner trait `__FooImpl` with `__spec_*` method declarations.
2. Generates a compile-time assertion that `size_of::<*const dyn Foo>() == 2 * size_of::<usize>()`.
3. Generates `impl<'a> dyn Foo + 'a { ... }` with two primitive helpers — `__devirt_raw_parts(&Self) -> [usize; 2]` and `__devirt_vtable_for::<T: __FooImpl + 'static>() -> usize` — plus inherent methods for each user-declared trait method whose body is the vtable-comparison dispatch shim.
4. Generates public marker trait `Foo: __FooImpl` (no methods of its own).
5. Blanket impl: `impl<T: __FooImpl + ?Sized> Foo for T {}`.

### `r#impl!` Expansion
### `__devirt_define! { @impl ... }` Expansion

For `impl [hot] Foo for A { ... }` or `impl Foo for A { ... }`:
For `impl Foo for A { ... }`:
- Expands to `impl __FooImpl for A { fn __spec_method(...) { ... } }`.
- The `[hot]` marker is accepted for backward compatibility but is purely documentary: hot-path specialization is driven entirely by the trait's hot-type list in `r#trait!`, not by per-impl overrides.

### Dispatch Arms (inside `src/lib.rs`)
### Dispatch Arms (inside `__devirt_define!`)

Four arms handle the combinatorics of `&self`/`&mut self` × void/non-void. Each splits into an outer "set up `__raw`" arm and a recursive `*_chain` arm that walks the hot-type list:
- `@dispatch_ref` / `@dispatch_ref_chain` — `&self`, returns value
Expand All @@ -80,13 +101,15 @@ Recursive expansion (rather than `$()+` repetition) avoids macro_rules metavar d
### Verification Layers

- **Miri** (`cargo +nightly miri test -p devirt --lib`): runs the `#[cfg(test)] mod primitives` harnesses — fat pointer layout, vtable identity, and end-to-end `&self` / `&mut self` / `Box<dyn>` dispatch — under Stacked/Tree Borrows to catch aliasing violations in the unsafe transmute and `&mut` paths.
- **UI tests** (`tests/ui/`): trybuild compile tests; `.stderr` files capture expected error output for compile-fail cases.
- **UI tests** (`tests/ui/`): trybuild compile tests for `__devirt_define!` direct usage; `.stderr` files capture expected error output for compile-fail cases.
- **UI attr tests** (`tests/ui_attr/`): trybuild compile tests for `#[devirt]` attribute; gated on `macros` feature via `required-features`.
- **Equivalence test** (`tests/equivalence.rs`): verifies both APIs produce identical dispatch behavior.
- **Kani** (`tests/kani.rs`): bounded model checker proofs for N=1,2,3 hot types, plus a `mod vt` section that directly verifies vtable-primitive soundness.
- **Verus** (`crates/verify/`): full functional correctness proofs (Properties A, B, C) for the abstract `dispatch_spec`, plus a refinement lemma that proves `vtable_dispatch_spec` (a direct recursion over `(vt, hot_vts, values)` modelling the vtable-comparison shim) produces the same result as `dispatch_spec` on a projected `Seq<Option<u64>>`, so Properties A/B/C transfer to the vtable-comparison implementation without reproof.

## Key Constraints

- `#![no_std]` throughout `crates/core`
- `paste` is a **regular** (not dev) dependency — it's needed at compile time during macro expansion
- Benchmarks require LTO to be meaningful (profile.bench sets `lto = "thin"`)
- `syn`, `quote`, `proc-macro2` are workspace dependencies used by `crates/macros`
- Workspace lints are very strict: `deny` on suspicious/complexity/perf/style/cargo/pedantic/nursery clippy groups. `unsafe_code` is `deny` (not `forbid`) so `crates/core` can locally `#![allow(unsafe_code)]` — all other crates still disallow unsafe.
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*", "fuzz"]
resolver = "3"

[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/Kab1r/devirt"
Expand All @@ -15,6 +15,10 @@ trybuild = "1"
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
devirt = { path = "crates/core" }
devirt-macros = { path = "crates/macros", version = "0.2.0" }
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
vstd = { version = "=0.0.0-2026-04-12-0118", default-features = false }

[profile.bench]
Expand Down
135 changes: 81 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,67 +12,96 @@ through `&dyn Trait`, the generated dispatch shim extracts the vtable
pointer from the fat pointer and compares it against compile-time-known
vtable addresses for each hot type. On a match, the data pointer is
reinterpreted as `&HotType` and the method is called directly (fully
inlined under LTO) — no vtable lookup, no indirect call. On a miss, the
shim falls through to a single vtable call via the hidden `__spec_*`
method. Callers use plain `dyn Trait` — no wrappers, no special calls,
zero API change at call sites.
inlined) — no vtable lookup, no indirect call. On a miss, the shim falls
through to a single vtable call via the hidden `__spec_*` method.
Callers use plain `dyn Trait` — no wrappers, no special calls, zero API
change at call sites.

## LTO required
## Usage

The default API uses a proc-macro attribute:

```rust
use std::f64::consts::PI;

struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
struct Triangle { a: f64, b: f64, c: f64 }

// 1. Define trait — list hot types in the attribute
#[devirt::devirt(Circle, Rect)]
pub trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
fn scale(&mut self, factor: f64);
}

This crate relies on cross-function inlining **and** cross-CGU vtable
deduplication. **Without LTO, the vtable-comparison may always miss
(because the trait and the hot type's impl live in different codegen units
and their vtables are not deduplicated), silently degrading to plain
`dyn Trait` dispatch.**
// 2. Hot type — vtable-cmp match, direct inlined call
#[devirt::devirt]
impl Shape for Circle {
fn area(&self) -> f64 { PI * self.radius * self.radius }
fn perimeter(&self) -> f64 { 2.0 * PI * self.radius }
fn scale(&mut self, factor: f64) { self.radius *= factor; }
}

Add this to your `Cargo.toml`:
#[devirt::devirt]
impl Shape for Rect {
fn area(&self) -> f64 { self.w * self.h }
fn perimeter(&self) -> f64 { 2.0 * (self.w + self.h) }
fn scale(&mut self, factor: f64) { self.w *= factor; self.h *= factor; }
}

// 3. Cold type — falls back to vtable
#[devirt::devirt]
impl Shape for Triangle {
fn area(&self) -> f64 {
let s = (self.a + self.b + self.c) / 2.0;
(s * (s - self.a) * (s - self.b) * (s - self.c)).sqrt()
}
fn perimeter(&self) -> f64 { self.a + self.b + self.c }
fn scale(&mut self, factor: f64) {
self.a *= factor; self.b *= factor; self.c *= factor;
}
}

// 4. Use — completely normal dyn Trait
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
```

### Without proc macros

If you prefer zero proc-macro dependencies, disable the default `macros`
feature:

```toml
[profile.release]
lto = "thin"
codegen-units = 1
[dependencies]
devirt = { version = "0.2", default-features = false }
```

## Usage
Then use the declarative macro:

```rust
use devirt;

// 1. Define trait — list hot types in brackets
devirt::r#trait! {
pub Shape [Circle, Rect] {
devirt::devirt! {
pub trait Shape [Circle, Rect] {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
fn scale(&mut self, factor: f64);
}
}

// 2. Hot type — vtable-cmp match, direct inlined call under LTO
devirt::r#impl!(Shape for Circle [hot] {
fn area(&self) -> f64 {
core::f64::consts::PI * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * core::f64::consts::PI * self.radius
}
fn scale(&mut self, factor: f64) {
self.radius *= factor;
devirt::devirt! {
impl Shape for Circle {
fn area(&self) -> f64 { PI * self.radius * self.radius }
fn perimeter(&self) -> f64 { 2.0 * PI * self.radius }
fn scale(&mut self, factor: f64) { self.radius *= factor; }
}
});

// 3. Cold type — falls back to vtable
devirt::r#impl!(Shape for Triangle {
fn area(&self) -> f64 { /* ... */ }
fn perimeter(&self) -> f64 { /* ... */ }
fn scale(&mut self, factor: f64) { /* ... */ }
});

// 4. Use — completely normal dyn Trait
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
```

Both APIs produce identical expanded code.

## When to use

Best when a small number of hot types dominate the population (80%+ of trait
Expand All @@ -91,10 +120,16 @@ objects). Common scenarios:

## Performance characteristics

| Path | Cost |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hot type dispatch | Single `cmp` against a RIP-relative vtable address + direct, inlined method call under LTO. **No indirect call.** |
| Cold type dispatch | Adds ~0.3 ns per hot type over plain vtable dispatch — one `lea + cmp + jne` per hot type before the vtable fallback. Keep the hot list to ≤3 types to keep this overhead below a single cache-miss cycle. |
| Path | Cost |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hot type dispatch | Single `cmp` against a RIP-relative vtable address + direct, inlined method call. **No indirect call.** |
| Cold type dispatch | Adds ~0.3 ns per hot type over plain vtable dispatch — one `lea + cmp + jne` per hot type before the vtable fallback. Keep the hot list to ≤3 types to keep this overhead below a single cache-miss cycle. |

LTO is **not required** — all dispatch logic expands via macros into the
user's crate, so there are no cross-crate function calls to inline and
vtable deduplication works within a single crate via COMDAT groups. LTO
may still improve performance for other reasons in your project but is not
necessary for devirt to work correctly.

## Benchmarks

Expand Down Expand Up @@ -140,10 +175,6 @@ pays the full branch misprediction cost on every call.

Notes:

- **LTO is required.** Without it, the trait and the hot type's impl may
end up in different codegen units and their vtables may not be
deduplicated, so the comparison always misses and dispatch silently
degrades to plain vtable.
- **Keep the hot list to ≤3 types.** Each hot type adds one `cmp + branch`
to the cold path, and more than three becomes a net loss on
cold-dominated workloads.
Expand All @@ -156,11 +187,7 @@ Notes:
Run benchmarks yourself:

```bash
# With LTO (default)
cargo bench --bench dispatch

# Without LTO (to stress-test)
RUSTFLAGS="-C lto=off -C codegen-units=256" cargo bench --bench dispatch
```

## License
Expand Down
15 changes: 15 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "devirt"
version.workspace = true
edition.workspace = true
rust-version = "1.85"
license.workspace = true
repository.workspace = true
readme = "../../README.md"
Expand All @@ -11,14 +12,28 @@ categories = ["no-std", "rust-patterns"]

[dependencies]
paste.workspace = true
devirt-macros = { workspace = true, optional = true }

[features]
default = ["macros"]
macros = ["dep:devirt-macros"]

[dev-dependencies]
criterion.workspace = true
trybuild.workspace = true

[[example]]
name = "shapes"
required-features = ["macros"]

[[test]]
name = "ui_attr"
required-features = ["macros"]

[[bench]]
name = "dispatch"
harness = false
required-features = ["macros"]

[lints]
workspace = true
Loading
Loading