A head-to-head comparison of two Rust CEL (Common Expression Language) implementations, focused exclusively on protobuf message input/output — the primary use case for CEL in production systems.
| cel-cxx | rscel | |
|---|---|---|
| Implementation | Rust bindings to cel-cpp via cxx FFI | Pure Rust CEL interpreter with bytecode VM |
| Protobuf input | Serialized bytes (&[u8]) |
Box<dyn MessageDyn> |
| Protobuf output | Extract bytes via Value::as_protobuf_bytes() |
Not supported |
| Type checking | Full static type checking against descriptor pool | None (dynamic only) |
28 protobuf-specific tests covering scalars, nested messages, repeated fields, maps, enums, well-known types, message construction, and output extraction.
cel-cxx: 28/28 pass. rscel: 12/28 pass.
| # | Feature | CEL Expression | cel-cxx | rscel |
|---|---|---|---|---|
| 1 | Scalar string field | msg.name |
PASS | PASS |
| 2 | Scalar int field | msg.id |
PASS | PASS |
| 3 | Bool field access | person.active |
PASS | PASS |
| 4 | Nested message access | person.address.city |
PASS | PASS |
| 5 | Deep nested access | order.buyer.address.zip |
PASS | PASS |
| 6 | Enum as int | person.status == 1 |
PASS | PASS |
| 7 | Enum named constant | person.status == test.Status.STATUS_ACTIVE |
PASS | FAIL |
| 8 | Repeated field size | person.tags.size() |
PASS | FAIL (panic) |
| 9 | Repeated field index | person.tags[0] |
PASS | FAIL (panic) |
| 10 | Repeated field exists | person.tags.exists(t, t == 'cel') |
PASS | FAIL (panic) |
| 11 | Repeated message index | order.items[0].name |
PASS | FAIL (panic) |
| 12 | Repeated message filter | order.items.filter(i, i.price > 1000).size() |
PASS | FAIL (panic) |
| 13 | Repeated message all | order.items.all(i, i.price > 0) |
PASS | FAIL (panic) |
| 14 | Map field access | order.labels['priority'] |
PASS | FAIL (panic) |
| 15 | Map field in |
'priority' in order.labels |
PASS | FAIL (panic) |
| 16 | Map field size | order.labels.size() |
PASS | FAIL (panic) |
| 17 | WKT Timestamp | event.created_at > timestamp('2023-...') |
PASS | FAIL |
| 18 | WKT Duration | event.ttl == duration('3600s') |
PASS | FAIL |
| 19 | WKT Wrapper unbox | event.optional_count == 42 |
PASS | FAIL |
| 20 | WKT Struct dot access | event.metadata.env |
PASS | FAIL |
| 21 | has() on present field |
has(msg.name) |
PASS | PASS |
| 22 | Boolean expression | msg.id > 10 && msg.name == 'Alice' |
PASS | PASS |
| 23 | Arithmetic | msg.id * 2 + 1 |
PASS | PASS |
| 24 | String concat | 'Hello, ' + msg.name |
PASS | PASS |
| 25 | Message construction | test.SimpleMessage{name: 'Bob', id: 25} |
PASS | FAIL |
| 26 | Protobuf output bytes | Extract result as proto bytes | PASS | FAIL |
| 27 | Complex comprehension | order.items.filter(i, i.price > 1000).size() > 0 |
PASS | FAIL (panic) |
| 28 | Ternary on proto field | msg.id > 40 ? 'big' : 'small' |
PASS | PASS |
rscel fails on repeated fields and maps because its protobuf integration calls get_singular_field_or_default() which panics on non-singular fields. It also lacks support for well-known types, named enum constants, message construction, and protobuf output extraction.
16 tests where both engines must produce identical results. All pass, with one documented semantic difference:
.size()returnsIntin cel-cxx andUIntin rscel
All benchmarks model production usage patterns: environments and compiled programs are cached, only bind + evaluate is measured on the hot path.
Run on Apple Silicon. Results are representative — exact numbers will vary by machine.
| Benchmark | cel-cxx | rscel | Speedup |
|---|---|---|---|
Simple field (msg.name) |
1.63 us | 8.42 us | 5.2x |
Boolean expr (id > 10 && name == 'Alice') |
1.98 us | 8.99 us | 4.5x |
Nested field (person.address.city) |
2.84 us | 9.00 us | 3.2x |
| Complex expr (arithmetic + string + ternary) | 2.43 us | 9.66 us | 4.0x |
| Operation | Time |
|---|---|
| eval + extract proto bytes | 1.83 us |
| eval bool only | 1.55 us |
rscel cannot extract protobuf bytes from evaluation results.
| Operation | Time |
|---|---|
exists() |
5.73 us |
filter().size() |
6.16 us |
all() |
5.72 us |
map().size() |
6.25 us |
rscel panics on repeated fields — these operations are exclusively available in cel-cxx.
| Engine | Time | Requests/sec |
|---|---|---|
| cel-cxx | 15.6 ms | ~641k/s |
| rscel | 85.2 ms | ~117k/s |
cel-cxx is 5.5x faster at sustained throughput.
| Engine | Time |
|---|---|
| cel-cxx | 8.3 us |
| rscel | 11.1 us |
| Engine | Time |
|---|---|
| cel-cxx | 32.4 us |
| rscel | 23.8 us |
rscel is 1.4x faster on cold start due to lighter compilation (no type checking, no FFI overhead).
| N evals | cel-cxx | rscel | Winner |
|---|---|---|---|
| 1 | 31.9 us | 20.0 us | rscel 1.6x |
| 10 | 46.7 us | 97.2 us | cel-cxx 2.1x |
| 100 | 225.9 us | 907.6 us | cel-cxx 4.0x |
| 1000 | 2.02 ms | 8.98 ms | cel-cxx 4.4x |
The crossover point is ~3 evaluations — after that, cel-cxx's faster eval dominates the total cost.
cel-cxx wraps Google's cel-cpp, which uses an optimized C++ evaluation engine with efficient protobuf field access via the descriptor/reflection API. Protobuf messages stay as serialized bytes and are deserialized directly by the C++ runtime.
rscel is a pure Rust bytecode VM. Each evaluation requires constructing a Box<dyn MessageDyn> and binding it through the protobuf reflection layer, plus interpreting bytecode rather than running compiled native code.
The compilation speed difference goes the other way: rscel's compiler is a lightweight Rust parser that emits bytecodes, while cel-cxx performs full type checking against the protobuf descriptor pool and crosses the FFI boundary.
In a real deployment (policy engine, API gateway, event processing):
- Expressions are compiled once and cached — compilation cost is amortized to near zero
- The hot path is bind + eval, executed per request — this is where cel-cxx's 4-5x speedup compounds
- Protobuf I/O is the standard format — cel-cxx handles the full protobuf feature set (repeated, maps, WKTs, output extraction) while rscel only handles singular scalar fields
- Rust stable (1.80+)
- CMake and a C++17 compiler (cel-cxx builds cel-cpp from source)
cel-cxx is pulled from the feat/native-protobuf-support branch. rscel uses version 1.0.4 from crates.io (the latest version compatible with stable Rust).
# Run capability tests (feature matrix)
cargo test --test capability -- --nocapture
# Run conformance tests (both engines must agree)
cargo test --test conformance
# Run benchmarks
cargo benchNote: the first build will take several minutes as cel-cxx compiles cel-cpp from source via CMake.
cel-comparison/
├── proto/test.proto # Shared protobuf schema
├── build.rs # Compile proto for both prost and protobuf-rs
├── src/
│ ├── lib.rs # CmpValue type, encoding helpers, shared modules
│ ├── cel_cxx_runner.rs # cel-cxx wrapper (bytes-based API)
│ └── rscel_runner.rs # rscel wrapper (MessageDyn-based API)
├── tests/
│ ├── capability.rs # 28 feature matrix tests
│ └── conformance.rs # 16 same-result tests
└── benches/
└── protobuf.rs # criterion benchmarks (7 groups)