Repository navigation
Conversation
…ler stack out of boot defaults/ironruby.rb reopened Gem::Installer at boot to prepend IronRubyNoExtensionBuild. Reopening an autoloaded constant triggers the autoload, so every IronRuby process loaded rubygems/installer.rb and with it package, the tar readers, the extension builders, security, user interaction and the openssl shim - 34 files CRuby does not load until something installs a gem, about a third of the cost of require "rubygems", and an OpenSSL that was defined in every process without a require (and an "openssl" entry in Gem.loaded_specs). Prepend from a Gem.const_added hook instead, the moment rubygems/installer.rb creates the class, whether it gets there through the autoload or one of the plain requires in RubyGems and Bundler. Boot now loads the same 22 rubygems/rbconfig/monitor features CRuby does. WIP: timing and gates pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er's class Struct#initialize was Ruby (ruby4.rb): every Point.new(1, 2) built the members array, asked respond_to?(:keyword_init?), called the class's keyword_init? block method, checked args.last.is_a?(Hash) and splatted into __struct_initialize__. It is now RubyStructOps.Initialize: one positional value per member, the last not a Hash, on a class not built with keyword_init: true, stores the values in place; every other call goes to the unchanged Ruby code, renamed __struct_initialize_slow__ and handed the arguments as `*args` would have been (keyword mark cleared). __struct_initialize__ is registered directly instead of being an alias of the old initialize. RubyContext.TryGetInstanceData took a lock and a Dictionary lookup for every value-type object - every `1.is_a?(Hash)` asks for an Integer's immediate class that way. Entries of that table are never removed, so a published count of zero now answers without the lock. Point.new(1, 2) loop (1M): 3801ms -> see Gates line; CRuby 340ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ands An outlined (OSR) loop never had its self: an @x read referenced an unbound parameter, the lambda failed to compile and every loop touching an instance variable stayed generic. The loop site now passes self after the locals tuples and JitLoopCompiler binds it. `sum += @x` was refused even then: the read is an object, and only numeric operands compile. The loop is compiled with its self at hand, so what the variable holds right now is a type profile: an Integer or Float there is read through a guard (JitRuntime.GuardInt/GuardLong/GuardDouble) that unboxes it while it still is one and deopts when it is not - the generic loop then re-runs the iteration and does whatever the operator does with the new value. Reads are not observable, so the body stays restartable. Instance variable writes are still refused in an outlined loop: a FrozenError leaving the copy part way through would strand the scope's locals at their entry values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or#next over Array Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyword-parameter prologue the prism bridge emits read the caller's keyword hash with Ruby-level calls: per call `size`, a `key?` and a `+` per keyword to rule out unknown keywords, then `key?` and `[]` for every keyword, and `dup` plus one `delete` per keyword for **rest - about ten dynamic calls for `def kw(a: 1, b: 2)`. They are now a new AST node, KeywordParameterRead, that compiles to three static helpers: RubyOps.KeywordArgumentsMatch (one call that is true exactly when there is no missing or unknown keyword - the existing array-difference code still words the ArgumentError, only on the error path), RubyOps.GetKeywordArgument (value or the DefaultArgument sentinel, so the default expression is still evaluated lazily and in order), and RubyOps.MakeKeywordRest. MRI binds keywords without calling Hash methods either, so a redefined Hash#[] / Hash#key? no longer changes how keywords bind (it used to; now it matches MRI). RubySymbol caches its hash code. The symbol's string is frozen, but GetHashCode re-hashed it (for some content representations copying it to a System.String first) on every Hash insert and lookup with a Symbol key - 15% of the call_kwargs profile after the change above. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ard and body constants WIP: gates pending. - RubyCallAction.BindPrecompiled: IsVariableCall (a bare `foo`) was missing from the flags a precompiled dispatcher accepts, so every receiver-less, argument-less call got a rule of its own (interpreted first, then compiled per site) instead of the shared dispatcher. The flag only selects the NameError a missing method raises, and a dispatcher is only made for a method that was found. - MemberDispatcher.IsCurrentFor: test for an exact RubyObject before casting to IRubyObject; the interface cast is a runtime helper call on every dispatch. - MethodDefinition.TransformBody: the declaring scope and module constants are typed as their exact class, so the per-call cast out of the constants array is an inline compare instead of two CHKCASTCLASS_SPECIAL helper calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read or write of a global ($x) looked the name up in the context's dictionary under GlobalVariablesLock on every access: in the gvar_get loop RubyContext.TryGetGlobalVariable was 83% of the time. Each `$x' / `$x = v' in compiled code now gets a GlobalVariableSite that remembers the GlobalVariable object the name denotes, stamped with a version of the name table. The version moves only when a name starts or stops denoting an object (define, alias, delete, the first assignment of a new global), never on a plain assignment, and comes from one process-wide counter so a stamp can never match another context's table. An ordinary global (GlobalVariableInfo) is then one version check and a field read or write; special, read-only and library-defined variables still run their GetValue/SetValue under the lock, and an assignment of anything but an ordinary global takes the old general path (validation, creation). trace_var handlers fire as before, after the assignment. The verbose "not initialized" warning is unchanged. Code saved to disk keeps the old uncached calls. Release, Util/bench, best of 5 interleaved (box heavily loaded, base spread 0.79-1.93 s): gvar_get 0.786 s -> 0.328 s gvar_set 0.941 s -> 0.601 s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lls, cheaper dispatcher guard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Module#const_set and #autoload record the caller's location. The cheap path reads it off the interpreter's frame chain; when every frame there belongs to an internal file it falls back to a CLR stack walk with a full backtrace (~1ms). Under --disable-gems the host loads ruby4.rb directly, so every frame on the stack is the prelude's own and every one of its const_set calls (the Errno and Encoding tables make several hundred) paid for a walk that could only come back empty: a startup profile had 14% of `ir --disable-gems -e 1` in RubyStackTraceBuilder under Module#const_set. Loader.LoadCorePrelude now marks the thread while ruby4.rb runs as the host's prelude, and TryGetCallerSourceLocation answers "no location" at once in that state - what the walk answered. With RubyGems on, gem_prelude.rb's frame is always found first, so nothing changes there. const_source_location of the prelude's constants is unchanged in both modes ([] without gems, gem_prelude.rb:9 with). ir --disable-gems -e 1 (Release, best/median of 7 interleaved, one bench-lock call; box loaded, base spread 0.63s): base 2.366 / 2.770 s -> 2.316 / 2.633 s (an earlier run: base 2.315 / 2.425 -> 2.015 / 2.158, base spread 0.89s) ir -e 1: unchanged within noise. Gates: sp-tests 0, sp-lang 2934/0/0, spec/core/module 1050/0/0 (= base), ir --disable-gems -e 1 2.366s -> 2.316s (median 2.770 -> 2.633) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…locks A block defined in interpreted code (top-level script code, the first calls of any method) is handed the interpreter's LightLambda delegate, and the block's dispatcher keeps that delegate for every later Proc of the block. Even after the lambda has compiled itself, each invocation went through LightLambda.RunN - a check and a generic cast - before reaching the compiled code. The dispatcher now subscribes to LightLambda.Compile and swaps in the compiled delegate, which is the very delegate RunN would have called. Every yield also called RequireLambdaArity, which walked the parameter signature (RefusesKeywords loops over the parameters) only to return for a plain block. The check is now an inlined test of proc kind plus a flag the dispatcher caches when its signature is set; the full check runs only for lambdas and keyword/**nil signatures, exactly when it did anything before. Smaller: a BlockParam made for a yield or a Proc#call skips the managed thread id read (it only ever runs on its creating thread, so Break's thread check always passed), and the post-yield checks return early when the block finished normally. Release, on-CPU seconds of the timed section (main thread schedstat), best of 5, base/mine alternating in one bench-lock call; box load avg 50-140: times_loop 0.668 -> 0.484 (base spread 0.668-0.737) yield_0 0.745 -> 0.667 (base spread 0.745-1.68) proc_call 0.457 -> 0.421 (base spread 0.457-0.800) Full bench.rb list, best of 3: yield_3 0.478 -> 0.397, upto_loop 0.477 -> 0.402, each_loop 0.461 -> 0.407, proc_new_call 0.186 -> 0.152; no regression outside the noise (re-ran the outliers 5x: deep_hierarchy 0.975, while_loop 0.949, fib 1.02 - none of them reach the changed code). Gates: sp-tests 0, sp-lang 2934/0/0, times_loop 0.668s -> 0.484s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he bridge LightCompiler (DLR patch 0100): CompileMethodCallExpression and EmitCall called MethodInfo.GetParameters() - which copies the parameter array - and ran CallInstruction.Create's checks for every call expression, only to find the instruction in CallInstruction's cache. A cache hit now skips both: a method is only ever cached once it is known to need none of that handling (no by-ref parameters, not a value-type instance method, not an array accessor or dynamic method). In a profile of loading a 110k-line file, GetParameters and CallInstruction.Create were 38% of LightCompiler.CompileTop. PrismAstBridge: SourceSpans were found with a binary search of the line starts for both ends of every node; nodes come in source order, so the lookup now starts at the line the last one ended on (and the end from the start's line). Line starts are found with IndexOf. BuildArguments fills an array instead of a List it then copies. Regexp literals were translated in full at parse time only to look for Onigmo's nested-quantifier warning; a pattern without two quantifier characters in a row (and no whitespace or # for extended mode) cannot draw it and skips the translation. Measured (Release, best of 3, user CPU; one bench-lock call, base and this tree alternating): loading a generated 22k-line file (400 modules/classes, 4800 defs) 6.40s -> 5.53s (base spread 6.40-6.44); `ir -e 1` 3.82s -> 3.72s (best of 5, base spread 3.82-4.08 - within noise). Gates: sp-tests 0, sp-lang 2934/0/0, spec/core method+unboundmethod+proc+regexp+binding 908/0/0 (= base), big400.rb load 6.40s -> 5.53s user Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irectly Binding keyword parameters no longer hashes anything: a keyword is a Symbol, equal only to itself, so Hash#HasKeywords and Hash#TryGetValueByIdentity (Hash.Keywords.cs) scan the few entries of a keyword hash for the very object - starting at the entry where the method declares the keyword, which is where callers mostly put it - and probe the table as usual only for hashes of more than 8 entries. `f(a: x)`, `f(a: x, b: y)` and `f(a: x, b: y, c: z)` with Symbol-literal keys build their keyword hash with RubyOps.MakeKeywordArgumentsHash1-3: no item array, none of SetHashElement's String/nil key conversions. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gated state of the previous (WIP) commit b334b18; no further change. Kernel#send, BasicObject#__send__ and Kernel#public_send looked their inner call site up in RubyContext.GetOrCreateSendSite on every call: a lock plus a Dictionary<Key<string, RubyCallSignature>> lookup that hashes the name and boxes the signature - 94% of #send's own time in a profile of send_call. Each overload now takes a SendSiteStorage (created per rule, so per send site): a few name -> CallSite entries compared by reference, filled once, with the context's shared sites as the fallback past 8 names (and for the library-internal SendMessageOpt callers). The inner site is also private to the send site now, so it stays as monomorphic as the send site is. Kernel#respond_to? keeps its answers in a RespondToCacheStorage: (class, method version, name, include_all) -> bool, kept only when the answer came from method lookups alone (a user-defined respond_to_missing? is still called every time) and only while the class's method version is unchanged. RubyModule.GetMethodLookup(name, out version) reports the version a lookup was published under (-1 when it must not be kept), so the memo is exactly as sound as the class's own lookup cache, which registers its lookups for invalidation the way call sites do. Release, best of 5 interleaved A/B runs (bench-lock): send_call 0.434s -> 0.216s (base spread 0.434-1.10, mine 0.216-0.266) respond_to 0.249s -> 0.216s (base spread 0.249-0.272, mine 0.216-0.299) Initializers regenerated; registered names unchanged. Specs: spec/core/kernel/{respond_to,respond_to_missing,send,public_send, method,public_method}, spec/core/basicobject, spec/core/module/{refine, refinements,using,prepend,define_method,alias_method,undef_method, remove_method,private,public}: 580 examples, 0 failures (base: same). Gates: sp-tests 0, sp-lang 2934/0/0, send_call 0.434s -> 0.216s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lookup, OSR ivar reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ble reference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ork for plain blocks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per spans and args in the bridge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…send and respond_to? Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions while ruby4.rb boots Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acktrace Every raise used to build its backtrace eagerly in the first Ruby frame that saw the exception: two CLR stack walks, then a MutableString per frame (method resolution, encoded-name parsing, library labels), and then a dynamic call to #set_backtrace - which the prelude (ruby4.rb) overrides, so it ran Ruby code that parsed every frame into a Thread::Backtrace::Location on every raise. Now CaptureExceptionTrace keeps the stack walks and the interpreter's saved frames (RubyStackTraceBuilder.CapturedTrace) and RubyExceptionData.Backtrace formats them on first use, with exactly the inputs the eager build used. #set_backtrace is only called when it has been redefined - IronRuby's own (library or prelude) is skipped, as MRI's setup_exception skips the basic definition; the prelude's #backtrace_locations parses the strings on demand as before. A raised exception's instance_variables are now [] as in MRI (they used to show the prelude's @__backtrace_* bookkeeping). Kernel#raise asks HasBacktrace so re-raising doesn't format the trace. raise_rescue (Util/bench, scale 0.1, Release, 5 interleaved pairs in one bench-lock call): base 6.45 s best (6.45-10.38) -> 1.98 s best (1.98-4.49). raise/rescue loop in an interpreted method, 5000 raises: 2.45 s -> 0.87 s. Output identical to base on a backtrace/full_message/cause/thread/eval/ catch/loop/Enumerator probe script. Gates: sp-tests 0, sp-lang 2934/0/0, raise_rescue(0.1) 6.45s -> 1.98s; spec/core/exception+kernel raise/fail/caller/caller_locations/catch/throw/loop+thread raise/backtrace+enumerator next+fiber 742 ex 1 F = base; spec/command_line 175/0/0 = base Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prism prefixes every serialized DefNode with its length so that method bodies can be loaded lazily. The loader (generate.rb) can now skim a def body instead of building it - noting whether it yields or calls super without a block (UsesBlock is needed when the def runs) and whether it holds anything the AST bridge reports at parse time (defined?, a regexp that may warn, class << x, class/module) - and the bridge then creates the MethodDefinition with a body built on first use: loaded from prism's buffer and mapped in the method's own scope, after its parameters, when the method is first compiled. Bodies with parse-time reports, eval'd code and Coverage runs stay eager. Most methods a file defines are never called (startup defines ~2300 and calls ~150), so their bodies are never loaded or mapped. Status: Debug and Release build; sp-tests Debug running; semantic checks (yield, super/zsuper, nested defs, binding.local_variables, caller lines, regexp/defined? warnings, Invalid yield, unused-block warning, threads) match base/CRuby. Not gated yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… direct call DispatchIntrinsics: the call binder folds Kernel#respond_to? with a symbol argument (answered by lookups alone, no refinements) into a constant rule guarded by the symbol and the receiver's class version, and binds send/__send__ with a symbol as the call of the named method (one dispatch instead of two). Per-class cap of 32 specialized rules each. Fold passes local semantics tests on Debug; send intrinsic not yet built or gated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o classes ruby4.rb runs at every startup and most of its cost is binding a call-site rule for each top-level call it makes. - The 169 `alias_method :a, :b` statements in class/module bodies become `alias :a :b`. Both end in RubyModule.AddMethodAlias on the same module; the keyword compiles to a direct call and binds nothing. Method tables after startup (every module's methods with owner, visibility, arity, parameters, original name) are identical with and without RubyGems. - The Errno table loop no longer asks the classes it has just made whether they have an Errno/Message constant of their own: a fresh class has none, and every one of them is a new receiver, so each question bound its own rule (~200 binds). Classes the C# library defined are still asked. ir -e 1 (Release, same binary, StdLib with/without this change, best/median of 7 interleaved in one bench-lock call): 2.924 / 3.005 s -> 2.829 / 2.931 s (spread 0.33 s); CPU 3.226 -> 3.034 s. Gates so far: sp-tests 0; spec/core/enumerable 536/0/0, exception 250/0/0, encoding 633/0/0 (= base); sp-lang pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Encoding section derives some 270 constant names from Encoding.name_list with regexp matches, a gsub and two blocks per name - a tenth of a second of CPU at every start. The outcome depends only on the names the runtime knows and the constants Encoding starts with, so it is recorded (name=CONSTANT, in the order the loops set them) and replayed when both match exactly; any other runtime runs the original loops. Encoding.constants (order included) and their values are identical, and the fallback path gives the same result. ir -e 1 (same protocol): CPU 3.034 -> 2.959 s, wall within noise (2.834 / 2.901 -> 2.824 / 2.942 s). Cumulative with the previous commit, base StdLib -> this: ir -e 1 2.873 -> 2.687 s, ir --disable-gems -e 1 1.731 -> 1.627 s (best of 7). Gates so far: sp-tests 0; spec/core/encoding 633/0/0 (= base), enumerable 536/0/0, exception 250/0/0; sp-lang pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… out of line Gates so far: sp-build Debug/Release OK, sp-tests Debug failures=0, spec/core/method + spec/core/module + spec/core/unboundmethod + kernel caller/binding/local_variables/ __method__/send specs 1499 examples 0F/0E. spec/language run still to do (cancelled for the coordinator's pause). - RubyMethodBody: a method body's primary delegate is the light compiler's LightLambda, which stays in front of the IL it compiles after a few dozen calls - one more delegate hop and a cast helper (CHKCASTANY) on every call. Dispatchers made over the interpreted delegate are recorded and repointed at the compiled delegate when LightLambda.Compile fires; binds after that get the compiled delegate from GetDelegate. - RubyOps.TraceLineEvent: NoInlining. The JIT inlined its never-taken body into the TracePoint test in front of every statement: f1 in a method-call chain was 873 bytes of machine code, 486 now - which matters to 30k_methods' instruction cache. ruby-bench 30k_methods (with 3cac183), best iteration, 2 runs each under bench-lock: base 5803ms -> 2885ms (base runs 5803/6337, mine 2885/2981). Reduced 1000-method chain (thread CPU time, best of 4): 6.80s -> 4.75s; call_empty -X:NoJIT 1.79s -> 1.41s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e method A rule bound for a Ruby-object receiver tests the receiver's immediate class version against a constant, so every class - and every new version of one - needs a rule of its own: built as an expression tree, overload-resolved and light-compiled, although all of them call the same method. Class bodies are the worst case (private, attr_reader, alias_method, const_set, module_function, define_method... all receive a different singleton class), and so is any class whose version a method definition elsewhere has bumped. `ir -e 1` built 2326 rules, 1681 of them for a binder that had already bound one for a receiver of the same CLR type. Method versions are unique across classes and never reused, so a version identifies a class in one state. RubyCallAction now keeps, per binder, the methods it has bound rules for. When a second class (or version) resolves the call to the same library or Ruby method, the rule it gets tests the class version against a RuleVersionSet instead of a constant; every further class is resolved again under the hierarchy lock and, if it finds the same method, its version is added to the set and the existing rule is handed out - no rule built or compiled. Monomorphic sites keep the constant test. Only rules whose code depends on nothing but the method, the call signature and the arguments are shared (RubyLibraryMethodInfo, RubyMethodInfo; not protected methods, refinements, c_call tracing, super/virtual/interop calls); a shared rule that does not match a call (other argument types) is not handed out twice in a row, so the site gets a rule of its own. `ir -e 1`: 2326 rules built -> 1052 built + 88 shared + 719 reuses. Startup, Release, best of 7 in one bench-lock call (box load avg ~80, so base runs spread 5.9-8.2 s): `ir -e 1` 5856 ms -> 4974 ms (-15%); an earlier best of 6: 3379 -> 2819 ms (-17%). `--disable-gems -e 1` 2984 -> 2895 ms (within noise). Util/bench call/yield/send/respond_to/ method_missing/super_call subset, best of 3: no change beyond noise. spec/core module, class, basicobject, method, kernel: 3705 examples, 0 failures, as base. Gates: sp-tests 0, sp-lang 2934/0/0, ir -e 1 5.856s -> 4.974s (best of 7, load ~80) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… an Array without a fiber Every Fiber is a CLR thread here, so a resume/yield round trip is two thread handoffs. The handoff was Queue#push/#pop (Monitor wait + PulseAll) and every Fiber.current went through Thread.current[] (three locks, three dictionary lookups). Now: - Thread.__fiber_mailbox__/__fiber_post__/__fiber_take__ (ThreadOps.Fiber.cs): a one-slot C# FiberMailbox. Take spins briefly (adaptive per mailbox, capped at 40us, never on one CPU) before it falls back to a Monitor wait, so a quick round trip costs cache-line transfers instead of two kernel wake-ups. Take also does __set_fiber_owner__'s work with the owner's RubyThreadInfo cached. Thread.Interrupt (Thread#kill/#raise) wakes a blocked take as it woke Queue#pop; the parked exception is delivered outside the lock. - Fiber.current reads a [ThreadStatic] copy (per RubyContext) of Thread.current[:__ir_fiber_current__]; Thread.__fiber_current_set__ writes both, so the C# readers of the fiber-local key are unchanged. - Enumerator#next/#peek over the built-in Array#each (ary.each, ary.to_enum) walks the Array with a cursor (Enumerator#__ir_array_cursor__) instead of suspending #each in a fiber thread. Array#each runs no Ruby code between elements and re-reads the length before each, which the cursor mirrors, so growing/shrinking arrays, peek, feed, rewind, StopIteration#result (the Array) and dup (shared position) behave as before. Only taken when neither the enumerator's #each nor the Array's has been redefined, aliased, overridden or prepended. Driving it from another thread raises FiberError without abandoning the iteration, as MRI does. New benchmarks enum_next_array, enum_next_gen, fiber_new in Util/bench/bench.rb. A/B, one bench-lock call, 5 interleaved rounds, best (spread): fiber_switch 0.25 base 2.066s (2.07-2.62) -> 0.890s (0.89-2.76) enum_next_gen 0.2 base 0.770s (0.77-0.81) -> 0.580s (0.58-1.02) enum_next_array 0.2 base 1.505s (1.51-1.68) -> 0.193s (0.19-0.28) The spin pays when the two threads get CPUs at the same time; on a busy bench slot the mine runs spread up to the base numbers. Specs: spec/core/fiber, spec/core/enumerator, spec/core/thread, spec/library/prime, spec/library/zlib/gzipreader: 1160 examples, 1 failure, identical to base on the same run (thread/backtrace/location/label_spec.rb:61, "BasicObject#instance_exec" label, fails on base too in a full-dir run). Util/enum-matrix.rb output identical to base. Gates: sp-tests 0, sp-lang 2934/0/0, fiber_switch 2.066s -> 0.890s, enum_next_array 1.505s -> 0.193s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verified on 09001d8 (this commit changes no files): - Util/bench call_kwargs, best of 6 interleaved in one bench-lock call: base 0.958 s -> 0.524 s (base runs 0.96-2.41 s, mine 0.52-1.18 s; the box is loaded and the base runs are bimodal). - ruby-bench keyword_args (Util/rubybench/run.sh --ir-only, ms per iteration, 2 runs each): base 3919/4218 -> 1980/2273. - In-process best-of-4 thread CPU time per 1M calls: call_kwargs 0.77 -> 0.40 s, add(left:, right:) 0.74 -> 0.38 s, required keywords 0.74 -> 0.41 s, positional + keyword 0.60 -> 0.39 s. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: the same 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, call_kwargs 0.958s -> 0.524s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o no keywords Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LowerKeywords began every keyword prologue with `?kw? ||= {}', because a block
or lambda is passed nil for a parameter its caller omitted. A method never is
(its caller omitted it => the optional-parameter default fires), so there the
statement only turned a nil passed positionally into an empty keyword hash:
`def m(k: 1); m(nil)' returned 1 instead of raising, and so did
`def m(x, k: 1); m(1, nil)', `Klass.new(nil)' for a keyword initializer,
`method(:m).call(nil)' and `send(:m, nil)'. A method's prologue no longer
has it, so the separation check raises MRI's "wrong number of arguments
(given 1, expected 0)"; blocks and lambdas are unchanged. JitKeywords
accepts the prologue with or without the statement.
Also: Util/jit-matrix.rb probes `m(nil)' and a keyword initializer (instance
variable stores, a frozen receiver, super with keywords); identical to CRuby
with and without -X:NoJIT. Util/bench gets new_kwargs (a keyword
initializer, which the JIT now specializes): tipref 0.518 s -> 0.376 s
(best of 6 interleaved under bench-lock, tipref spread 0.518-0.664;
CRuby 0.275 s), 592 -> 408 B per `Pt.new(x: i, y: 2)'.
Spot specs (spec/core/method, proc, unboundmethod, struct, data,
basicobject, Kernel#send/public_send, define_method, Class.new): 1134
examples, 0 failures, same as tipref.
Gates: sp-tests 0, sp-lang 2934/0/0, new_kwargs 0.518s -> 0.376s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elow Content is in the three commits before this one (this commit only records that their combined tree passed every gate): - RubyRegex: a CLR Regex that has run 10000 matches is rebuilt with RegexOptions.Compiled (plain and \G-anchored variants counted separately, so a StringScanner-only pattern compiles only the regex it runs). Same engine and pattern tree, identical results; the interpreter is ~3x slower on JSON's string pattern. - Kernel#Integer/#Float (ruby4.rb): a String with exception: true goes straight to the C# parser instead of through the block and its rescue frame (also drops the __numeric_conversion__ frame from backtraces, closer to MRI). - ProtocolConversionAction.Make caches the last action per runtime instead of a locked dictionary lookup on every library call. - MatchData caches GroupCount (was re-parsing group names on every access) and offers TryGetGroupByteOffsets; StringScanner uses it instead of allocating int[2] per match/group, and takes an Integer index without a conversion site. - RubyOps.CreateRegexL/B: a cached literal no longer allocates the closure for the factory delegate on every evaluation. - Util/bench/bench.rb: strscan_tokens. Differential tests vs CRuby 4.0.6 and tipref (regex tier-up past the threshold, Integer()/Float() incl. errors, StringScanner groups/named/pre/post/mutation): output identical to tipref; ruby-json parse result identical to CRuby. Specs (tipref vs this): spec/library/stringscanner+core/regexp+core/matchdata 681 ex 0 F both; spec/core/string+kernel/Integer+Float 4307 ex 0 F both. Measured (bench-lock, interleaved; box load 20-70, noise +-20-40%): - ruby-bench ruby-json (rb-compare, 3 rounds): 1546 -> 1364 ms (1.13x) - ruby-json driver (100 parses, best of 3): 914 -> 748 ms - strscan_tokens: 2.18 -> 1.66 s; per op (best of 24 batches): scan(str re) 764 -> 525 ns, self[1] 223 -> 147 ns, Integer() 894 -> 449 ns, Float() 1298 -> 433 ns; scan allocates 695 -> 527 B - bench.rb --list sweep (57 benches, best of 3): geomean 0.98, all outliers re-run 6x alternating order: within noise (0.97-1.05) Gates: sp-tests 0, sp-lang 2934/0/0, ruby-json 1546ms -> 1364ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ata; ASCII literal skip without copying; gsub no-match path - RubyRegex.MatchWindowBounds finds a scanning match with Regex.EnumerateMatches over the window (no Match, no MatchData) where CLR chars are the bytes, and returns a PendingMatch that re-runs the same CLR regex over the same immutable text when a group is asked for (identical MatchData, unaffected by later mutation of the subject). - StringScanner uses it for the operations that answer a length/position. - MatchLiteral compares an anchored ASCII pattern in place (no ToByteArray). - RubyRegex.Matches asks for the first match directly (Match/NextMatch is how Regex.Matches is defined), so a #gsub with no match builds no collection. skip(/\s*/) allocates 367 -> 71 B. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ivar run Class#new on a class with a plain Ruby initialize spent most of its time in the generic method body: a RubyMethodScope + MutableTuple per call (~200 B), then one shape transition (an interlocked CAS) per instance variable. The method JIT already takes `def initialize(a, b) @A = a; @b = b end`, but not the common shapes: - optional parameters with a literal default (nil/true/false/Fixnum/Float): the entry fills an omitted one in, so `def initialize(left = nil, right = nil)` is specialized (gcbench's Node); arity goes from 3 to 6 (JitStub4..6); - parameters that are only ever stored into an ivar stay boxed (no unbox on entry, no re-box on the store), and `@n = 0' boxes its literal once; - an initializer's opening run of `@x = <param|literal|self>' writes to distinct variables gives a fresh RubyObject its final-shape slot array and instance data in one CAS (JitRuntime.Begin/CommitInitialIVars); anything else (existing ivars, frozen, non-RubyObject self) falls back to the one-by-one writes. A write to a frozen object in a typed body now deopts, so FrozenError comes from the generic body; the entry runs the generic body after the catch handler rather than in it, and its own frame (jitentry$) is left out of Ruby backtraces. Adds Util/bench/bench.rb object_new_init. Allocation per Node.new (4 ivars, optional args): 344 B -> 160 B; C.new(1,2,3,4): 360 -> 160 B. Node.new loop (3M, bench-lock, best of 5): 1.163 s -> 0.643 s. ruby-bench, tipref 843df62 -> this, one bench-lock call, interleaved, best of 2x3 itrs (box heavily loaded, tipref spread ~20-30%): object-new-initialize 656 -> 402 ms, gcbench 17866 -> 11093 ms, splay 1072 -> 904 ms, binarytrees 2152 -> 2165 ms (flat). Util/bench/bench.rb --list: no change beyond noise (best of 3, spreads up to 80%). Spot specs (core/class, kernel ivar/freeze/dup/clone/caller, basicobject, exception backtrace, marshal dump, objectspace): same 0 failures / 20 errors as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, gcbench 17.87s -> 11.09s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…state in bytes Gates and measurements for the two commits before this one (bbc94ee, 90d42db). BlockParam: the caller kind and the proc-converter bit share one readonly byte, the return reason and the source ProcKind another; with the two bools that leaves 4 bytes of small state, so a BlockParam is 48 B (was 56 B, 64 B on the speedup tip). Proc: a proc is only ever active as a frame while it is its own converter, so the RuntimeFlowControl field it inherits holds any other converter and a flag in _kind says "converted by itself"; the separate Converter field is gone and a library call converting a literal block writes one reference instead of two. Proc is 64 B (was 72 B, 80 B on the speedup tip). Kind masks the flag off. Bytes per call (GetAllocatedBytesForCurrentThread, 1M calls), speedup tip -> now: [].reverse_each {} / [].index {} 168 -> 112 obj.ruby_func {} 232 -> 216 proc {} 168 -> 112 lambda {} 248 -> 176 Whole Util/bench runs (GC.GetTotalAllocatedBytes, load-independent): block_literal_cfunc 390.5 -> 278.5 MB, yield_0 646.5 -> 582.8, proc_call 230.7 -> 198.9, lambda_call 230.5 -> 198.7, proc_new_call 146.7 -> 111.8; unchanged (+-0.2 MB) for fiber_switch, float_arith, case_when, sort_strings, raise_rescue, call_empty, block_pass, map_loop. Timing (the box was heavily loaded, spreads 20-130%): rb-compare best of 3 rounds x 5 iterations send_cfunc_block 1413 -> 1294 ms (1.09x; 1.17-1.19x in two quieter runs on the previous commit), send_rubyfunc_block within noise (1.11x, 1.00x, 0.85x in three runs; interleaved ruby_func {} microbench 6 pairs: mine faster in 4, medians 198 -> 184 ns). Full Util/bench --list sweep (3 rounds, interleaved): no change outside the noise; the outliers (float_arith, fiber_switch) re-run at 6 rounds are within their spread and allocate exactly the same. Spot specs (proc, kernel lambda/proc/block_given/loop/tap/then/catch/binding, binding, array reverse_each/index/each/map, integer times, module define_method/refine, instance_eval/exec, enumerator, thread/new, method/symbol to_proc, fiber, each_slice, hash each, exception): 187 files, 1557 examples, 0 failures - same as the tip. Differential scripts vs CRuby 4.0.6 (break/next/redo/return through library and Ruby calls, escaped procs, dup/clone/lambda copies, threads, fibers, Proc identity): identical. Gates: sp-tests 0, sp-lang 2934/0/0, send_cfunc_block 1413ms -> 1294ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommit below Content is in ee45fe8 (this commit only records that its tree passed every gate): #skip/#match?/#exist?/#skip_until find the match with Regex.EnumerateMatches and build the MatchData only when a group is read (re-running the same CLR regex on the same immutable text, so the groups are exactly what MatchWindow would have produced, also after the subject is mutated); an anchored ASCII String pattern is compared in place; a #gsub with no match builds no MatchCollection. Differential tests (StringScanner skip/match?/exist?/skip_until/scan/check x fixed_anchor x 6 subjects incl. binary, multibyte, astral; groups, named, captures, values_at, pre/post_match, dup, unscan, string mutation and replacement after a deferred match; gsub/gsub!/sub/scan incl. $~ after no match): output identical to tipref. spec/library/stringscanner+core/regexp+ core/matchdata 681 ex 0 F; spec/core/string+kernel/Integer+Float 4307 ex 0 F (same as tipref). Measured (bench-lock, interleaved; box load 20-70, noise +-20-40%): - ruby-bench ruby-json, tipref -> this (rb-compare, 3 rounds): 1670 -> 1181 ms (1.41x) - strscan_tokens, previous gate -> this (best of 6, alternating): 1.93 -> 1.60 s - skip(/\s*/) allocates 367 -> 71 B per call Gates: sp-tests 0, sp-lang 2934/0/0, ruby-json 1670ms -> 1181ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the keyword method specialized, what was left of a call like `add(left: 1, right: i)' was the caller's work: the keyword Hash it builds (~200 B, ~40% of ruby-bench keyword_args), the dispatch, and the entry reading the values back out of the Hash. A receiverless (or self.) call with 1-3 Symbol-literal keywords, at most two positional arguments and nothing else is now compiled with an inline cache in front of its dynamic site (Runtime/Jit/JitKeywordCall.cs). The arguments are evaluated once, in order, into temporaries; the cache resolves the name on the receiver's class (all visibilities, as a receiverless call may), keyed by class and global method version, and when it is a specialized keyword method whose prologue would bind exactly these keywords, calls its direct entry (JitStub.Direct: the keyword values in declaration order, defaults filled in, the same type and version guards as the ordinary entry). Any other case - a different kind of method, method_missing, unknown or missing keywords, a failed guard, a deopt, refinements anywhere, tracing - answers NoDirect and the call is made exactly as before, Hash and all, from the same temporaries. A site whose receiver class keeps changing gives up after 8 misses. Only with -X:JIT. Util/jit-matrix.rb probes the direct path (private target, override in a subclass, singleton method, method_missing, overflow/Float/String values); identical to CRuby with and without -X:NoJIT. A 50-seed random keyword-method differential (signatures, defaults, values, missing/unknown keywords) is identical with the direct path and without it. Measured under bench-lock, best of 3 interleaved (the box was very loaded: tipref's keyword_args ran 2.3 s here vs 1.0 s earlier): ruby-bench keyword_args: tipref 2299 ms, previous commit 1196 ms, this 304 ms (spreads 2299-2321 / 1196-1236 / 304-382; CRuby 246 ms unloaded). 2 x 3M `add(left:, right:)' in a method: 1606 -> 478 ms. Allocation per call 256 -> 48 B. A receiverless keyword call to a method the JIT does not specialize: 2282 -> 2248 ms (no cost). `ir -e 1': best 2189 -> 2123 ms, median 2421 -> 2463 (noise). Spot specs (method, proc, unboundmethod, struct, data, basicobject, Kernel#send/public_send, define_method, Class.new, refine, private): 1193 examples, 0 failures, same as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, keyword_args 1196ms -> 304ms (tipref 2299ms) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The method JIT and OSR now take `[a, b]' (no splat, at most five elements: the
fixed-arity RubyOps.MakeArrayN helpers the generic body uses). Building an array
has no observable effect, so it does not make a body unsafe to deopt. This makes
binarytrees' bottom_up_tree a typed, self-recursive body with no RubyMethodScope:
def bottom_up_tree(depth)
return [nil, nil] unless depth > 0
depth -= 1
[bottom_up_tree(depth), bottom_up_tree(depth)]
end
ruby-bench binarytrees, tipref 843df62 -> this, one bench-lock call, interleaved,
best of 2x3 itrs (tipref spread ~30%): 2377 -> 1443 ms.
Micro spot check (best of 5): array_index, while_loop, fib, ackermann unchanged.
Differential tests vs CRuby (arrays from typed bodies are fresh, mutable, unfrozen;
Bignum elements; OSR loops building arrays): identical.
Gates: sp-tests 0, sp-lang 2934/0/0, binarytrees 2.377s -> 1.443s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kips, regex tier-up, cheaper Integer()/Float() of Strings ruby-json 1703 -> 1222 ms (1.39x), strscan_tokens 1.94 -> 1.25 s (reviewer's pinned A/B vs 843df62). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l frame; smaller BlockParam and Proc Bytes per block-passing call 168 -> 112; send_cfunc_block 1.07-1.18x (best/median, reviewer's A/B). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # Util/bench/bench.rb
…efaults, arity up to 6, initial ivar stores) and short array literals binarytrees 1.83x, gcbench 1.65x, object-new-initialize 1.53x (reviewer's pinned A/B vs 843df62). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # Util/bench/bench.rb
…t receiverless keyword calls; keyword prologue no longer accepts a positional nil keyword_args 2075 -> 227 ms (9.1x, CRuby ~246), call_kwargs 1.29x (reviewer's pinned A/B vs 843df62). Merged with w2-object-alloc's stub changes: JitStub/JitCompiler/JitScreen take both a keyword method's keyword locals and a positional method's literal-default optionals (the two never meet: a signature mixing them is lowered to a splat), stored-only parameters are worked out over the typed parameters including keyword locals, JitStub4-6 take keywords, and the backtrace filter also hides jitdirect$ entries. Util/jit-matrix.rb identical to CRuby with the JIT on and with -X:NoJIT -X:NoOSR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kParam/Proc Brings the block scope reuse of sp/w2-block-scope (843df62..fcea2410) onto speedup with block-pass: the BlockParam keeps the parked scope in its break-target slot while no break is pending, the proc keeps it in its rare state, and neither keeps a scope on the first call. Adds yield_once_call and then_call benchmarks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry and the locals fetch test inline for a kept scope and call out only when there is one; the type test of a reused tuple runs only for a tuple; keeping a scope on the proc (and its thread id) is out of line and starts with the proc's third single call, so a block yielded once or twice allocates nothing more than before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the block scope reuse of sp/w2-block-scope (843df62..fcea2410) onto speedup after block-pass, and removes its single-yield regression: - the BlockParam keeps the scope of its last call in its break-target slot while no break/return/retry is pending (no new field: BlockParam stays 48 bytes), and only from its second call; a single-call flag rides in the packed caller flags; - the proc keeps it in its rare state (no new field: Proc stays the same size), from its third single call (yield or Proc#call), so a literal block yielded once or twice allocates nothing more than before; the call count lives in the proc's kind bits; - the entry and the locals fetch test inline for a kept scope and call out only when there is one, the tuple type test runs only for a tuple, and keeping a scope on the proc (and recording its thread) is out of line. Allocation per op (load-independent, GetAllocatedBytesForCurrentThread over 1M ops), speedup tip d6474b6 -> this: a.each { |x| x * 2 } 164 -> 12.7 B/elem N.times { |i| i * 2 } 200 -> 48 pr.call(i) { x * 2 } 248 -> 96 define_method body 248 -> 96 m1(i) { |y| y * 2 } (1x) 488 -> 488 yield twice 808 -> 808 i.then { |y| y * 2 } 488 -> 488 Mutex#synchronize 288 -> 288 yield 3x 1032 -> 1080 (rare state, scope not reused yet), 4x 1256 -> 1152 Util/bench (best of 6, alternating order, bench-lock; noise floor 0.07-0.13 s): each_loop 0.337 -> 0.292, times_loop 0.347 -> 0.312, upto_loop 0.321 -> 0.294, yield_once_call 0.768 -> 0.754, then_call 0.738 -> 0.685, yield_0 0.549 -> 0.500, yield_3 0.390 -> 0.359, block_pass 0.258 -> 0.249, block_literal_cfunc 0.294 -> 0.293, proc_call 0.349 -> 0.343 (best of 8). New entries yield_once_call and then_call cover a single-call block whose body makes a call. Differential scripts (matrix, reviewer's t4/t5/m, Util/block-scope-reuse-blockpass.rb) match CRuby with and without -X:NoJIT -X:NoOSR. Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.337s -> 0.292s, yield_once_call 0.768s -> 0.754s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427206c takes a scope kept on the BlockParam inline in the block (each, times, a yield in a loop) instead of through an out-of-line call; only the proc's own kept scope goes out of line. Probe (thread CPU ns/op, best of 6 alternating processes; tipref d6474b6 / 185b9be / this): each { |x| x * 2 } 61.6 / 36.8 / 32.3, each { |x| y = x * 2 } 59.2 / 37.4 / 33.5, N.times 64.6 / 45.7 / 39.3, pr.call 115.3 / 106.1 / 101.3, single yield 182.8 / 181.8 / 183.0 (noise 2-35). Util/bench vs tipref (best of 5-10, alternating): each_loop 0.278 -> 0.220, times_loop 0.261 -> 0.231, upto_loop 0.248 -> 0.226, proc_call 0.230 -> 0.222, block_pass 0.210 -> 0.214, block_literal_cfunc 0.215 -> 0.213, yield_0 0.349 -> 0.350, yield_3 0.242 -> 0.246, yield_once_call 0.453 -> 0.452 (n=10), then_call 0.477 -> 0.461 (n=10); noise floor 0.03-0.18 s. Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.278s -> 0.220s, yield_once_call 0.453s -> 0.452s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drop the proc-kept scope The block entry took the locals tuple of a scope kept on the BlockParam or proc and only then ran the safe point, where a trap handler or finalizer calling the same block could take that scope and leave the interrupted call a new scope over the same tuple. TakeReusableBlockScope now runs the safe point and then takes the scope off the BlockParam; the tuple is read from the taken scope. The proc-kept scope (Proc.RareState.ReusableScope, the call counter, single-call BlockParams) is gone: scopes are only kept on a BlockParam that calls its block repeatedly. Util/block-scope-reuse-race.rb: trap/finalizer regression checks (fail on the previous commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its Proc captures the scope on every call, so the reuse path was pure overhead (ruby-bench send_cfunc_block: 20 literal blocks per outer block call). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it, not in every Proc A literal block's Proc is made on every call of its enclosing code; with blocks that nest a block literal excluded from scope reuse, only Method#to_proc, Symbol#to_proc and string eval can still put a reusable block scope into a Proc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… BlockParam parking only) Fixes the review of dfbb66f (commits 3ac3d35..78d4133): - The entry takes the parked scope off the BlockParam right after the safe point (TakeReusableBlockScope) and reads the locals tuple from the scope it took, so a trap handler or finalizer running at the safe point can no longer leave a call a new scope over another call's tuple. Util/block-scope-reuse-race.rb (trap/finalizer regressions) fails on dfbb66f (stale 12-17, 13-17, 3-6) and passes here and on CRuby. - The proc-kept scope (Proc RareState slot, call counter, single-call BlockParams) is gone; only a BlockParam that calls its block repeatedly parks a scope. - No reuse path for a block with a nested block literal (its Proc captures the scope on every call); the Proc constructor no longer marks scopes, Method#to_proc, Symbol#to_proc and string eval do. A/B vs tipref d6474b6, Release, bench-lock, interleaved, order flipped per round: thread-CPU ns/op, best of 6-8 processes (noise = median-best spread 2-25%): each{x*2} 68.0 -> 59.9, each{y=x*2} 62.9 -> 38.1, map 101.8 -> 66.3, any? 121.1 -> 44.1, times 81.5 -> 45.3, yield loop 109.8 -> 53.4 (164 -> 12 B/op); yield once/2x/3x 172.4/252.8/336.3 -> 175.2/261.2/340.4 (noise 3.5-9%), then 371.5 -> 370.3, Mutex#synchronize 393.1 -> 394.8, pr.call 103.9 -> 105.7, define_method 99.5 -> 99.9 (noise 2-8%); warm-up 3x300k fresh process within 2-6% (noise 2-8%). Util/bench (s, best of 5): each_loop 0.268 -> 0.208, times_loop 0.254 -> 0.221, yield_loop 0.246 -> 0.211, any_loop 0.210 -> 0.169, map_loop 0.212 -> 0.193, yield_once_call 0.444 -> 0.453, then_call 0.473 -> 0.482 (noise 6-30%); ruby-bench block_methods 7517 -> 5832 ms (best of 4). Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.268s -> 0.208s, block_methods 7517ms -> 5832ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one call A block yielded repeatedly by one call (each, map, select, times, a yield in a loop) takes the scope its previous call left on the BlockParam instead of allocating a new one; the safe point runs before the scope is taken, so a trap handler or finalizer cannot end up sharing it. Scopes kept by a Proc, Binding, nested block, eval or TracePoint are never reused. Reviewer's pinned A/B vs d6474b6: each/map/select/any/times/yield loops 16-55% faster, ~165 -> 12 B per yield; ruby-bench block_methods 7473 -> 5918 ms. Single-call shapes +1-3% (inside the 5-7% noise floor); Util/bench cmp_branch +6.6 ms from a moved gen0 GC (0.43 MB more allocated at startup). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Performance work on IronRuby, 129 commits on top of 1672d83 (4.0.1). Every change was gated on its own branch: Debug and Release builds, IronRuby.Tests 0 failures, ruby/spec
spec/language2934 examples / 0 failures / 0 errors, the spec/core and spec/library directories of the touched code compared against the previous tip, and a CRuby 4.0.6 differential for the behaviour it touches. The later rounds were also checked by an independent adversarial reviewer before merging.Measured effect (Release, pinned and interleaved A/B on a shared, loaded machine: expect ±15 %)
Util/bench(50 micro benchmarks) vs master at 33e7775: geomean 1.21x, total 26.1 s -> 11.2 s. Biggest: fiber_switch 7.1x, raise_rescue 5.5x, module_function_call 5.3x, send_call 3.0x, respond_to 2.0x, call_kwargs 1.75x.ir -e 1: 1.86 s -> about 1.25 s.Main changes
jitentry$frames in backtraces.respond_to?(:lit)/send(:lit)intrinsics, keyword-splat merging, smaller method/block scopes (rare state out of line), scope reuse across the yields of one call, a literal block's Proc as its own flow-control frame.Build/dlr-patches(0100-compile-pipeline, 0100-exceptions) applied in order onto upstream.Behaviour fixes found along the way (now match MRI)
m(nil)to a keyword method raiseswrong number of argumentsinstead of treatingnilas "no keywords".Array#reverse_each/#rindexfollow MRI when the block shrinks the array (no element visited twice).rb_str_concat_literals;nil.to_sis US-ASCII.Hash#eachno longer raises KeyNotFoundException when the block deletes a later key.Known
; required keyword: ksuffix (pre-existing).cmp_branchshows +6 ms from where a gen0 GC lands (0.4 MB more allocated at startup).🤖 Generated with Claude Code
Summary by CodeRabbit