From 274e0b987f509d6734a047563a2c8e58f1c5fa5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:07:38 +0000 Subject: [PATCH 1/7] perf: cut Ruby<->C++ conversion overhead in tensor and forward paths 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 Claude-Session: https://claude.ai/code/session_01ACSJ1DPT6ryZy6gdvnaJ5K --- .gitignore | 6 + bench/README.md | 113 +++ .../__pycache__/bench_models.cpython-311.pyc | Bin 0 -> 6610 bytes bench/bench_models.py | 92 +++ bench/compare.rb | 52 ++ bench/make_pt_models.py | 132 ++++ bench/pt_to_pte.py | 103 +++ bench/results/baseline.json | 537 +++++++++++++ bench/results/optimized.json | 726 ++++++++++++++++++ bench/run_bench.rb | 238 ++++++ ext/executorch/executorch.cpp | 395 +++++----- ext/executorch/utils.h | 162 +++- lib/executorch.rb | 97 +-- 13 files changed, 2368 insertions(+), 285 deletions(-) create mode 100644 bench/README.md create mode 100644 bench/__pycache__/bench_models.cpython-311.pyc create mode 100644 bench/bench_models.py create mode 100644 bench/compare.rb create mode 100644 bench/make_pt_models.py create mode 100644 bench/pt_to_pte.py create mode 100644 bench/results/baseline.json create mode 100644 bench/results/optimized.json create mode 100644 bench/run_bench.rb diff --git a/.gitignore b/.gitignore index 9097d09..0514744 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,9 @@ test/support/models/*.pte # Coverage /coverage/ + +# Benchmark artifacts (regenerate with bench/make_pt_models.py + bench/pt_to_pte.py) +/bench/models/ +/bench/results/*.json +!/bench/results/baseline.json +!/bench/results/optimized.json diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..cfcd338 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,113 @@ +# executorch-ruby benchmarks + +An eval + profiling harness for the gem. It answers two questions: + +1. **Are we right?** Does Ruby produce the same numbers PyTorch does? +2. **Where does the time go?** How much of an inference call is actual + inference, and how much is the Ruby ↔ C++ boundary? + +## The pipeline + +``` +bench_models.py model definitions (shared by both scripts below) + │ + ▼ +make_pt_models.py → models/.pt eager module, torch.save + → models/.meta.json shapes, param count, eager latency + → models/.io.bin golden input + output, raw float32 + │ + ▼ +pt_to_pte.py → models/.pte torch.export → to_edge → to_executorch + │ + ▼ +run_bench.rb → results/