Skip to content

[Perf][Rust] Optimize match_any! ordered conversion and exact-leaf dispatch - #692

Merged
tqchen merged 10 commits into
apache:mainfrom
tlopex:perf/rust-match-any-lookup-minimal
Jul 31, 2026
Merged

[Perf][Rust] Optimize match_any! ordered conversion and exact-leaf dispatch#692
tqchen merged 10 commits into
apache:mainfrom
tlopex:perf/rust-match-any-lookup-minimal

Conversation

@tlopex

@tlopex tlopex commented Jul 29, 2026

Copy link
Copy Markdown
Member

This PR improves both Rust match_any! dispatch paths without changing how matching works.

Ordered dispatch

When an arm does not match, the internal conversion now returns Err(()) instead of creating a TypeError. This makes failed checks cheaper while keeping the public TryFrom<AnyView> API unchanged.

Custom TryInto matchers are still supported and continue to run in source order.

Exact-leaf dispatch

For a match with many exact final object types, the macro builds one TypeIndex → ArmId table through OnceLock.

The table uses the smallest pattern TypeIndex as its starting point:

base = minimum pattern TypeIndex
arm_ids[pattern_type_index - base] = source ArmId

Each call then selects an arm directly with:

arm_ids[runtime_type_index - base]

This is an O(1) lookup. Only the selected object handle is created, and its type check is not repeated.

The direct table is used when there are at least 20 typed arms, every pattern matches one exact final runtime type, there are no guards, and bindings are simple names or _. Smaller matches, guarded arms, parent-type patterns, parameterized containers, and custom matchers continue using ordered dispatch.

The input value is still evaluated once. Duplicate types still select the first arm, and unmatched or non-object values still use the final _ fallback. A TypeId check prevents the same static table from being incorrectly shared by different generic pattern lists.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@tlopex
tlopex marked this pull request as draft July 29, 2026 21:22
@tlopex
tlopex marked this pull request as ready for review July 30, 2026 02:18
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@tlopex
tlopex force-pushed the perf/rust-match-any-lookup-minimal branch from e2fb57b to 3f859e0 Compare July 30, 2026 18:42
@tlopex tlopex changed the title [Perf][Rust] Optimize match_any leaf dispatch [Perf][Rust] Optimize match_any! ordered conversion and exact-leaf dispatch Jul 30, 2026
Comment thread rust/tvm-ffi/tests/test_match_any.rs Outdated
Comment thread rust/tvm-ffi/tests/test_match_any.rs Outdated
@tlopex

tlopex commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Here is the result of the benchmark:
All measurements use an x86-64 pinned CPU, O3 builds, preconstructed objects, and 31 rotated samples. T1 uses three shuffled seeds; the table reports the median across those seeds. The benchmark exercises the generated match_any! code directly.

A successful conversion checks the runtime type and constructs and drops the target handle, including the reference-count increment and decrement. A rejected conversion only checks the runtime type and returns Err(()), without constructing a diagnostic error.

Conversion workload Rust C++ ObjectRef::as<T>()
One type, match 8.428 ns 9.246 ns
One type, reject 1.393 ns 1.153 ns
Two types, first matches 8.438 ns 9.510 ns
Two types, second matches 8.398 ns 10.939 ns
Two types, neither matches 1.624 ns 0.934 ns

The lightweight Rust conversion is therefore in the same expected range as C++.

Shuffled runtime types

“Hits” is uniform across all typed arms. “All outcomes” is uniform across the typed arms plus one fallback outcome. Below 20 arms, the production macro keeps ordered dispatch; from 20 exact-leaf arms, it uses direct lookup.

Arms Production path Hits: Ordered Selected path Improvement All outcomes: Ordered Selected path Improvement
8 Ordered 8.533 ns 8.533 ns retained 8.731 ns 8.734 ns retained
12 Ordered 9.817 ns 9.813 ns retained 9.976 ns 9.968 ns retained
16 Ordered 10.713 ns 10.712 ns retained 10.788 ns 10.787 ns retained
20 Direct 12.355 ns 11.665 ns 5.6% 12.686 ns 11.256 ns 11.3%
32 Direct 19.461 ns 11.928 ns 38.7% 20.039 ns 11.629 ns 42.0%
48 Direct 30.866 ns 12.054 ns 60.9% 31.501 ns 11.917 ns 62.2%
64 Direct 44.733 ns 12.091 ns 73.0% 45.458 ns 11.988 ns 73.6%

Ordered dispatch grows with the number of preceding arms. Direct dispatch stays around 12 ns from 20 through 64 arms, showing the intended O(1) behavior.

Repeated 20-arm input

Repeated outcome Ordered Direct Direct change
First arm 8.363 ns 8.625 ns 3.1% slower
Middle arm 7.830 ns 8.638 ns 10.3% slower
Last arm 16.203 ns 8.644 ns 46.7% faster
Fallback 13.260 ns 1.630 ns 87.7% faster

This shows the expected tradeoff: a perfectly predictable early ordered arm can remain slightly faster, while later arms and misses benefit substantially from direct lookup.

First eligible call

The type metadata and objects are warm, but the call-site OnceLock is intentionally uninitialized.

First 20-arm outcome Ordered Direct
Last-arm match 290 ns 441 ns
Fallback 270 ns 411 ns

Direct lookup pays approximately 140–150 ns once to build the call-site table. Subsequent calls use the steady-state path measured above.

Optimized assembly

The O3 assembly confirms that the conversion and lookup helpers are inlined. After the OnceLock and pattern-list checks, the hot lookup performs:

runtime TypeIndex → subtract base → bounds check → u32 ArmId load → jump to selected arm

The selected conversion then proceeds directly to the expected reference-count operations without repeating the runtime type check.

Path Instructions executed per match Conditional branches per match Compiled function size
20-arm ordered 79.9 27.0 3,883 bytes
20-arm direct 46.2 10.0 5,111 bytes
64-arm ordered 248.4 71.0 5,863 bytes
64-arm direct 48.3 11.1 12,949 bytes

These instruction counts come from Callgrind and are simulated rather than hardware-counter measurements. Direct lookup generates more code because the initialization path, fallback, jump table, and all typed handlers remain at the call site, but it executes nearly constant work as the number of arms increases.

@tlopex
tlopex requested a review from tqchen July 30, 2026 23:58
@tqchen
tqchen merged commit a7eafcc into apache:main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants