Add eval/benchmark suite, cut Ruby↔C++ overhead, fix the install path, add CI - #1
Open
benngarcia wants to merge 7 commits into
Open
Add eval/benchmark suite, cut Ruby↔C++ overhead, fix the install path, add CI#1benngarcia wants to merge 7 commits into
benngarcia wants to merge 7 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a benchmark suite that converts
.ptcheckpoints to.pteand 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.mdis the full walkthrough.The eval harness (
bench/)Seven models spanning three regimes, from
x*2+1(pure overhead) to resnet18.pt_to_pte.pyworks 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_protectper elementRice routes every
Arrayelement access and every scalar conversion throughdetail::protect(), which is anrb_protect— a VM tag push plussetjmp. The tensor loop paid two per element in, one per element out: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 inforward()(the caller's Array holds them live, so the copy bought nothing), replacing exception-based type dispatch withData_Type::is_descendant, and flattening nested arrays withArray#concatinstead of aflat_mapthat allocated an object per leaf.tensor_new_flatto_a_flattensor_new_nestedallocsAlso 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
forwardwas 8.26 s against PyTorch eager's 25 ms.I built the XNNPACK delegate and measured rather than assuming:
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:
forward()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 --installdoesn't installextension/module/module.hor theruntime/executortree, both of which this gem includes directly.rake compilefails with "module.h header not found" on a perfectly good ExecuTorch build. Anyone following the README hit this.script/build-executorch.shhandles 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 av*tag: verify the tag matchesExecutorch::VERSIONand 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_dispatchruns it as a dry run.Other fixes
extconf.rbwhole-archived two operator libraries when both were present, which aborts the runtime at init on duplicate operator registration. Now picks one, withEXECUTORCH_OPS_LIBto 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 lostArray::Proxy's implicit conversion. This was needed just to build.changelog_uriboth assumed amainbranch; the default branch ismaster, so the changelog link would have 404'd on rubygems.org.Testing
70 tests, 0 failures, 0 skips.
add_mul.pteis wired in astest/support/models/simple.pte, so the 8 previously-skipped model tests now actually run — meaning the changedforward()path is covered. Newtest/binary_tensor_test.rbcovers the binary API including dtype round trips and byte-length mismatches.Before merging
release.ymlcan publish — repobenngarcia/executorch-ruby, workflowrelease.yml, environmentrelease. Until then the release job will fail at the publish step.## [Unreleased]CHANGELOG section;0.2.0seems right given the new public API.Generated by Claude Code