Skip to content

Add eval/benchmark suite, cut Ruby↔C++ overhead, fix the install path, add CI - #1

Open
benngarcia wants to merge 7 commits into
masterfrom
claude/executorch-ruby-evals-perf-f1cg52
Open

Add eval/benchmark suite, cut Ruby↔C++ overhead, fix the install path, add CI#1
benngarcia wants to merge 7 commits into
masterfrom
claude/executorch-ruby-evals-perf-f1cg52

Conversation

@benngarcia

Copy link
Copy Markdown
Owner

Adds a benchmark suite that converts .pt checkpoints to .pte and runs them through the gem, then fixes what it found. Also adds CI and a working ExecuTorch build script.

Measured on 4-core x86_64, Ruby 3.3.6, Rice 4.12, ExecuTorch 0.7.0. All five benchmark runs are checked into bench/results/ as the evidence; bench/FINDINGS.md is the full walkthrough.

The eval harness (bench/)

bench_models.py     model definitions
make_pt_models.py   → .pt checkpoints + golden input/output from PyTorch
pt_to_pte.py        → .pte  (torch.export → to_edge → to_executorch)
run_bench.rb        → evals vs PyTorch + per-phase timings and allocations
compare.rb          → diffs two runs

Seven models spanning three regimes, from x*2+1 (pure overhead) to resnet18. pt_to_pte.py works on any .pt, not just these. Every model's output matches PyTorch to ≤3e-6.

Timings are batched so microsecond-scale operations aren't swamped by clock overhead, and each phase stops early once it hits a time budget so resnet18 doesn't stall the suite.

Finding 1: the bindings paid an rb_protect per element

Rice routes every Array element access and every scalar conversion through detail::protect(), which is an rb_protect — a VM tag push plus setjmp. The tensor loop paid two per element in, one per element out:

float_data.push_back(static_cast<float>(
  detail::From_Ruby<double>().convert(data[i].value())));   // two rb_protects

That's ~3,000 cycles to read one float. Building a resnet18 input took 92 ms before any math ran.

Fixed by inlining the Float/Fixnum paths (falling back to Rice only for values that genuinely can raise or run Ruby code), building output arrays with rb_ary_new_capa + rb_ary_push, dropping a deep-copy of every input in forward() (the caller's Array holds them live, so the copy bought nothing), replacing exception-based type dispatch with Data_Type::is_descendant, and flattening nested arrays with Array#concat instead of a flat_map that allocated an object per leaf.

phase model before after
tensor_new_flat resnet18 92.20 ms 7.00 ms 13.2×
to_a_flat mobilenet_v2 1.105 ms 0.017 ms 64.6×
tensor_new_nested allocs resnet18 153,238 11

Also adds Tensor.from_bytes / #to_binary, an opt-in raw-buffer path that skips per-element conversion entirely: 92 ms → 0.53 ms.

Finding 2: the kernels were worth 658×

A default build links the portable reference kernels. resnet18 forward was 8.26 s against PyTorch eager's 25 ms.

I built the XNNPACK delegate and measured rather than assuming:

model portable XNNPACK PyTorch eager
resnet18 8262 ms 12.56 ms 658× 25.3 ms
mobilenet_v2 1618 ms 3.39 ms 477× 16.7 ms

Delegated ExecuTorch ends up ~2× faster than PyTorch eager on resnet18.

I also tested KERNELS_OPTIMIZED, since it needs no re-export and sounds like the easy win — it bought nothing measurable (8204 ms vs 8262 ms). These models are all convolution, which still falls back to portable. Worth knowing before reaching for it.

Finding 3: the two halves need each other

Neither piece looks impressive alone. On portable kernels the binding fixes barely move end-to-end time — which is exactly why a 0.6 µs/element conversion cost went unnoticed.

So I rebuilt the original bindings against the XNNPACK runtime rather than extrapolating:

model e2e forward() overhead
mobilenet_v2 107.9 ms 3.7 ms 96.6%
resnet18 116.4 ms 11.7 ms 90.0%

Building the mobilenet_v2 input cost 28× more than running the model. With both fixes plus from_bytes, end-to-end goes 107.9 ms → 5.26 ms (20.5×).

Fix the kernels first, because that's the 658× — but the moment you do, the bindings become the bottleneck.

Finding 4: the documented install path didn't work

cmake --install doesn't install extension/module/module.h or the runtime/executor tree, both of which this gem includes directly. rake compile fails with "module.h header not found" on a perfectly good ExecuTorch build. Anyone following the README hit this.

script/build-executorch.sh handles the whole thing: pinned clone, submodules, the matching buck2 binary (ExecuTorch's CMake shells out to it), configure, build, install, then copy the ~1300 headers the install step leaves behind. Verified end to end — a fresh prefix built by this script compiles the extension and passes all 70 tests.

CI

  • ci.yml — lint (Ruby/shell syntax, gem packaging, CHANGELOG entry) plus tests on Ruby 3.1–3.4 on Linux and 3.3 on macOS. ExecuTorch is built once per OS and cached; the test matrix restores that cache rather than paying a cold build per Ruby version.
  • release.yml — on a v* tag: verify the tag matches Executorch::VERSION and the CHANGELOG has an entry, build the gem, check the extension sources are actually in the package, publish via RubyGems trusted publishing (OIDC, no API key in secrets), cut a GitHub release. workflow_dispatch runs it as a dry run.

Other fixes

  • extconf.rb whole-archived two operator libraries when both were present, which aborts the runtime at init on duplicate operator registration. Now picks one, with EXECUTORCH_OPS_LIB to override. Related: self-registering libraries must be whole-archived or the linker silently drops them and you get a missing-operator error at model load.
  • Object input = inputs[i] no longer compiles against Rice 4.12, which lost Array::Proxy's implicit conversion. This was needed just to build.
  • The CI trigger and the gemspec's changelog_uri both assumed a main branch; the default branch is master, so the changelog link would have 404'd on rubygems.org.

Testing

70 tests, 0 failures, 0 skips. add_mul.pte is wired in as test/support/models/simple.pte, so the 8 previously-skipped model tests now actually run — meaning the changed forward() path is covered. New test/binary_tensor_test.rb covers the binary API including dtype round trips and byte-length mismatches.

Before merging

  • Trusted publishing needs a one-time setup on rubygems.org before release.yml can publish — repo benngarcia/executorch-ruby, workflow release.yml, environment release. Until then the release job will fail at the publish step.
  • The version bump is left to you. Changes are in a ## [Unreleased] CHANGELOG section; 0.2.0 seems right given the new public API.
  • The first CI run pays a cold ExecuTorch build (tens of minutes). Subsequent runs restore from cache.

Generated by Claude Code

claude added 7 commits August 5, 2026 05:07
Adds a benchmark suite (bench/) and fixes what it found.

The suite converts .pt checkpoints to .pte, runs them through the gem,
checks the output against PyTorch's, and times each leg of an inference
call separately. Models span three regimes so binding cost is visible
where it matters (tiny models) and honestly negligible where it doesn't.

What the profile showed: on tiny_mlp, 76% of an inference call was not
inference. Root cause is that Rice routes every element access and every
scalar conversion through detail::protect(), i.e. an rb_protect() -- a VM
tag push plus setjmp -- so reading one Float out of an Array cost two of
them, and writing one cost another. At ~0.6us in and ~1.1us out per
element, a 150k-element resnet18 input took 92ms to build before any math
happened.

Changes:

- utils.h: inline conversion helpers that take the fast path for Float and
  Fixnum and fall back to Rice's protected call only for cases that can
  actually raise or run Ruby code. Array building uses rb_ary_new_capa +
  rb_ary_push on an array that cannot raise.
- Tensor.create / to_a: use those helpers. 13x faster in, up to 65x out.
- Tensor.from_bytes / Tensor#to_binary: new opt-in raw-buffer path that
  skips per-element conversion entirely. 150k-element input: 92ms -> 0.5ms.
- Model#forward / #execute: stop deep-copying every input tensor on every
  call (the caller's Array holds it live for the duration, so the copy
  bought nothing), dispatch on type instead of catching Rice's conversion
  exception, and reuse the EValue buffer across calls.
- lib/executorch.rb: flatten nested input level-by-level with Array#concat
  instead of a recursive flat_map that allocated an intermediate Array per
  leaf. 153,238 allocations -> 11 for a resnet18 input, and jagged-array
  detection is preserved.
- utils.h / executorch.cpp: collapse three copies of the error-code switch
  into one error_name().
- extconf.rb-adjacent: Object input = inputs[i] no longer compiles against
  Rice 4.12 (Array::Proxy lost its implicit conversion); construct
  explicitly from .value().

Binding overhead on mlp_512x2 goes from 23.3% of an end-to-end call to
1.1%. All 59 existing tests pass, and every model's output still matches
PyTorch to within 1e-4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
The benchmark suite surfaced that forward() on resnet18 takes 8.3s against
PyTorch eager's 25ms on the same checkpoint. That gap is not in the
bindings -- it is the portable reference kernels a default ExecuTorch
build links, which have no vectorization or threading.

- extconf.rb: generalize the whole-archive handling that portable_ops_lib
  already had, and apply it to optimized_native_cpu_ops_lib and
  xnnpack_backend when they are present. Self-registering libraries must be
  whole-archived or the linker drops the registering object files and the
  failure surfaces much later as a missing operator or backend at model
  load.
- bench/pt_to_pte.py: --xnnpack lowers supported subgraphs via
  XnnpackPartitioner, which is required at export time for the delegate to
  do anything at runtime.
- test/binary_tensor_test.rb: cover Tensor.from_bytes / #to_binary,
  including dtype round trips and the byte-length mismatch errors.
- Rakefile: bench:prepare / bench:run / bench:compare.
- README: document the binary tensor path and the kernel choice.
- bench/FINDINGS.md: full writeup of both performance stories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
Built ExecuTorch with XNNPACK and optimized kernels and measured all four
combinations rather than assuming. Two corrections to the earlier writeup:

- EXECUTORCH_BUILD_KERNELS_OPTIMIZED bought nothing measurable (resnet18
  8204ms vs 8262ms). It covers elementwise and some BLAS-backed ops; these
  models are all convolution, which still falls back to portable. The
  earlier draft implied it would help.
- extconf.rb whole-archived both operator libraries when both were present.
  Each registers the full op set into the same global table, so that aborts
  the runtime at init on duplicate registration. Now picks one --
  optimized_native_cpu_ops_lib when available, portable_ops_lib otherwise --
  with EXECUTORCH_OPS_LIB to override.

The XNNPACK delegate is the real lever: resnet18 forward 8262ms -> 12.56ms
(658x), and 2x faster than PyTorch eager on the same machine.

That in turn is what makes the binding work matter. Measured with the
original bindings on the XNNPACK runtime, 96.6% of a mobilenet_v2 call was
binding overhead -- building the input tensor cost 28x more than running
the model. With both fixes, mobilenet_v2 end-to-end goes 107.9ms -> 5.26ms
using from_bytes, a 20.5x improvement that is invisible on portable
kernels.

- run_bench.rb: --variant flag to benchmark <model>.<variant>.pte
- results/: all five runs checked in as the evidence
- FINDINGS.md: rewritten around the measured cross-product
- README: XNNPACK build guidance with real numbers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
FINDINGS.md cites baseline_xnnpack.json, xnnpack.json, and
optimized_kernels.json as its evidence, but .gitignore was allowlisting
only two of the five runs. Keep all of them -- they are small and are the
whole point of the writeup. Also untrack a .pyc that slipped in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
--models add_mul,mnist_cnn was read as one model name and failed with an
unknown-model error. Split on commas as well as spaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
The documented install path did not actually work. `cmake --install` does
not install extension/module/module.h or the runtime/executor headers, both
of which this gem includes directly, so `rake compile` failed with
"module.h header not found" on a perfectly good ExecuTorch build. Anyone
following the README hit this.

- script/build-executorch.sh: clone at a pinned version, fetch submodules,
  fetch the matching buck2 (ExecuTorch's CMake shells out to it to generate
  source lists), configure, build, install, then copy the ~1300 headers the
  install step leaves behind. Verified end to end: a fresh prefix built by
  this script compiles the extension and passes all 70 tests.
  EXECUTORCH_BACKENDS=xnnpack opts into the delegate.
- .executorch-version: single source of truth, shared with CI's cache key.
- Rakefile: executorch:build_deps delegates to the script rather than
  keeping a second copy of the cmake flags.

- .github/workflows/ci.yml: lint (Ruby/shell syntax, gem packaging,
  CHANGELOG entry) plus tests on Ruby 3.1-3.4 on Linux and 3.3 on macOS.
  ExecuTorch is built once per OS and cached, since a cold build is tens of
  minutes; the test matrix restores that cache rather than rebuilding per
  Ruby version. Both jobs compute the cache key with the same expression.
- .github/workflows/release.yml: on a v* tag, verify the tag matches
  Executorch::VERSION and that the CHANGELOG has an entry, build the gem,
  check the extension sources are actually in the package, publish via
  RubyGems trusted publishing (OIDC, no API key in secrets), and cut a
  GitHub release. workflow_dispatch runs it as a dry run.

- CHANGELOG: Unreleased section covering this branch. Version bump left to
  the maintainer.
- README: install steps point at the script; troubleshooting explains the
  real cause of the header error; CI badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
The default branch is master. The CI push trigger and the gemspec's
changelog_uri both assumed main, so CI would never have run on pushes to
the default branch and the changelog link would 404 on rubygems.org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K
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.

2 participants