Skip to content

Latest commit

 

History

History
2362 lines (1942 loc) · 154 KB

bc2cpp-dynamic-site-census.md

File metadata and controls

2362 lines (1942 loc) · 154 KB

bc2cpp dynamic-dispatch site census

Historical: the measurements on this page were taken on the former maix/wio closed world, not the current closed world, which is the sealed RPG2000/2003 desktop host.

How much by-name dispatch is left in the C++ that bc2cpp generates for the former wio closed world, why each site is still dynamic, and what proof would remove it. scripts/bc2cpp/dynamic_site_census.rb reproduces every number below from the generated C++.

The most recent run, with its caveats, is bc2cpp-census-2026-10-19.md.

Method

mkdir -p /tmp/keep
MRBC=<host mrbc> BC2CPP_COVERAGE_KEEP_DIR=/tmp/keep ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/dynamic_site_census.rb /tmp/keep/shipped.cxx [--tsv sites.tsv]

# exact receiver origins (SiteOriginTable): the same pass also writes the origin table
MRBC=<host mrbc> BC2CPP_COVERAGE_KEEP_DIR=/tmp/keep2 BC2CPP_COVERAGE_ORIGIN_TABLE=/tmp/keep2/origins.tsv \
  ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/dynamic_site_census.rb /tmp/keep2/shipped.cxx --origins /tmp/keep2/origins.tsv [--tsv sites.tsv]

With the table, the generated shipped.cxx carries a /*SO:<label>:<index>*/ comment on every by-name line (the join key). Stripping those comments gives back the untagged file byte for byte (checked when the numbers below were measured).

shipped.cxx is the SKIP_UNSUPPORTED=1 pass of the whole-program wio build (all three compiled gems, the shipped set). Two runs of the same tree are byte-identical, so counts are comparable across revisions.

  • Call site: one source line in a generated method body that calls bc2cpp_send(M, recv, <index>, ...). The <index> is mapped back to a method name through bc2cpp_sym_names. The helper definitions are not counted.
  • Helper region: everything before the first generated *_impl body. A by-name call there is held by a helper; it is reached from every generated site that calls the helper.
  • Each site is classified by (a) its position (inside a known-class arm, in the else arm of an inline fast path, or bare), (b) the nearest preceding marker comment, (c) the POLY_DIAG line when the site has one, and (d) a heuristic for where the receiver register was last assigned.
  • The categories in the "why" table are exclusive; the first matching rule wins (see category in the script).
  • --tsv writes one row per body site (line, owner method, name, arity, fast-path flag or class_arm, marker family, guard shape, receiver origin, category, POLY_DIAG path). The owner tables are a grouping of the owner method's prefix; the "Now" numbers below were produced this way from 0a9adfc.

Results (master at 0a9adfc)

The previous snapshot of this section was master f70fef67 (PRs #1947-#1962 merged, 9,733 bc2cpp_send lines before that round); the follow-ups below carry the ADRs that landed since. "Then" is that snapshot, "Now" is a fresh run of the census on the shipped set of 0a9adfc (shipped.cxx, 441,136 lines, helper region lines 1..22,574).

Measure Then (f70fef67) Now (0a9adfc) Delta
bc2cpp_send call sites in generated bodies 5,081 2,325 -2,756 (-54.2%)
bc2cpp_send calls held in helpers 24 24 0
bc2cpp_send total (what a text count sees) 5,105 2,349 -2,756 (-54.0%)
mrb_funcall_with_block sites (BLOCK_FALLBACK markers 447 then, 462 now) 448 417 -31
mrb_funcall / _argv / _id in bodies 28 30 +2
mrb_funcall* held in helpers 5 5 0
bc2cpp_nomethod sites (error raise, not dispatch) 4,562 4,460 -102

The helper-held mrb_funcall* are not dispatch in the shipped build: two sit under #ifdef MRB_UTF8_STRING (String#size/length, ADR 0291; the project does not define it) and one is the puts of the guard-violation error path. The helper region also holds one mrb_yield_argv.

Relocated into helpers versus removed

A text count overstates a win whenever a by-name call moved into a shared helper. Counting the generated sites that can still reach a by-name call (their own bc2cpp_send, plus every call into a helper whose slow arm dispatches by name, plus the block and funcall sites):

Then Now
Own bc2cpp_send in bodies 5,081 2,325
Calls into bc2cpp_slow_* (ADR 0292) 3,542 3,274
Calls into bc2cpp_eqq (ADR 0293) 110 110
Calls into bc2cpp_getidx / getidx0 / setidx 2,584 2,397
mrb_funcall_with_block + body mrb_funcall* 476 447
Sites that can reach by-name dispatch 11,793 8,553
  • Of the net -3,240, -2,756 are removed bc2cpp_send sites in bodies. The rest is callers that stopped reaching a helper: 268 slow_*, 187 index-helper callers (2,584 to 2,397) and 29 block or funcall sites.
  • The shared helpers still hold by-name calls that thousands of generated sites reach (getidx 2,017 callers, slow_add_f 712, slow_sub_f 471, slow_mul_f 442, setidx 348, slow_gt 311, slow_lt 296, slow_div 272). The numeric helpers do the Float arithmetic natively where the old else arm sent, so the relocation is a speedup even though it is not a removal.
  • Net effect on sites that can reach by-name dispatch: -27.5% since f70fef67, against -54.0% on the text count. The helper-held calls barely moved, so the reachable count falls more slowly than the text count.

Where the remaining 2,325 sites sit

Position Then Now Share now
Else arm of an inline fast path (class or tag test above it) 3,442 1,841 79.2%
Arm of a class the guard proves exactly, still by name 1,198 33 1.4%
Bare dispatch, no guard at all 441 451 19.4%

The known-class arm, the second largest position last time, is almost gone: 1,198 to 33 (the ADR 0296/0297 class pools and unlisted-class arms and the follow-ups below). The 33 left are actor 17, load_face_bitmap 5, skills 4 and party 4 (23 in Game, 10 in RPG2k). Everything else is an else arm kept as a soundness fallback (1,841) or a bare send (451); removing the else needs a proof that it is unreachable (ADR 0290 turns such an else into a guard-violation raise).

By owner prefix of the generated method that holds the site:

Owner Sites
RPG2k 941
Game 591
RGSS 216
LCF 102
core and ext gems 475

The core and ext gems are Array 94, String 76, Enumerable 71, Hash 58, Range 49, StringIO 42, IO 27, Integer 19, Enumerator 13, Struct 11, File 7 and a tail of smaller owners.

The guard shape directly above the site is core_exact_class_chain 1,064, no_guard_nearby 531, owner_class_chain 340, rgss_native_class_guard 238 and numeric_tag_guard 152. The nearest marker family is POLY 493, NONE 397, POLY_SMALL_N 255, NATIVE_CORE_DIRECT 250, NATIVE_DIRECT 130 and INDEX_EXACT 127. 1,684 sites carry no POLY_DIAG line.

Top 25 method names

# Name Now Then Cumulative share
1 [] 208 619 8.9%
2 empty? 205 258 17.8%
3 size 187 260 25.8%
4 to_s 151 152 32.3%
5 length 85 97 36.0%
6 to_enum 66 63 38.8%
7 new 66 131 41.6%
8 to_i 60 60 44.2%
9 []= 51 267 46.4%
10 push 50 95 48.6%
11 width 47 113 50.6%
12 y= 44 87 52.5%
13 x= 44 83 54.4%
14 update 42 87 56.2%
15 name 37 54 57.8%
16 include? 37 53 59.4%
17 height 33 - 60.8%
18 inspect 28 - 62.0%
19 flash 25 - 63.1%
20 delete 22 - 64.0%
21 key? 21 - 64.9%
22 first 21 - 65.8%
23 to_f 20 - 66.7%
24 string 19 - 67.5%
25 pack 19 - 68.3%

241 distinct names remain (329 before). "-" means the name was outside the old top 25. party (264), dispose (169) and id (161) left the top of the table: they were the exact-class arms that sent by name. What is left is dominated by core container names ([], empty?, size, to_s, length are 836 sites, 36%) in a tag chain whose else is the only by-name path.

Why the sites are still dynamic (top categories)

Exclusive categories, largest first; the first matching rule wins (see category in the script). "Then" is the f70fef67 snapshot, which grouped a few categories differently ("-" marks one it did not list).

# Category Then Now Share now
1 Core tag chain else, receiver not an ivar 1,201 849 36.5%
2 Core tag chain else, receiver is an ivar 830 215 9.2%
3 RGSS native exact-class else 628 206 8.9%
4 CLOSED_WORLD kept: core_or_native 325 186 8.0%
5 POLY_DIAG genuinely dynamic (all sub-reasons) 469 475 20.4%
6 Numeric tag guard - 121 5.2%
7 No guard nearby 162 95 4.1%
8 CLOSED_WORLD kept: singleton_definer 168 91 3.9%
9 Known-class arm, still by name 1,198 33 1.4%
10 CLOSED_WORLD kept: dynamic_install 77 29 1.2%
11 Owner-chain default else - 21 0.9%
12 Everything else (opaque_definer 2, unlisted_class 2) 23 4 0.2%

The proof estimates the old snapshot carried (about 2,300-3,100 of its 5,081 sites removable with five proofs: class-arm lookup fixes, {Hash, nil} ivar sets, copy-propagated receiver classes, RGSS argument and receiver facts, and literal receivers) have largely been spent. The known-class lookup fixes took 1,198 to 33, the ivar sets 830 to 215, and the RGSS facts 628 to 206. What is listed below is what those proofs did not reach.

1. Core tag chain else, receiver not an ivar (849). The site tests Array/Hash/String/Integer tags inline and sends in the else: empty? 181, to_s 151, size 125, length 85, to_i 53, [] 47, push 26. Receiver origin (text walk on 0a9adfc; these five sum to 763 of the 849): register copy 410, unknown 122, direct-call result 113, indexed result 77, other 41. The lever is still a receiver class set carried through register copies, return-class tables and arguments so the else becomes a guard violation; the 849 are the receivers it has not proven. to_s (151) is interpolation of arbitrary values and stays dynamic.

2. Core tag chain else, receiver is an ivar (215). 136 embedded ivars and 79 plain ivar reads: size 62, [] 40, []= 26, empty? 24, push 22, pop 16.

3. RGSS native exact-class else (206). new 62, y= 36, x= 36, flash 18, update 10, z= 7, fill_rect 7. The receiver class is proven and an argument is not provably an Integer, so the native-direct arm keeps its else. Receiver origin (text walk on 0a9adfc; these four sum to 149 of 206): register copy 63, other 31, direct-call result 29, unknown 26.

4. CLOSED_WORLD kept: core_or_native (186). The name has a core or native definer the world cannot exclude: name 37, delete 18, map 17, string 16, resume 11, at 10, write 9. Receiver origin (text walk on 0a9adfc; these two sum to 93 of 186): unknown 60, register copy 33. Needs an exact receiver class per name (for resume, a Fiber).

5. POLY_DIAG genuinely dynamic (475). receiver_class_unresolved 210 (inspect 28, call 14, __to_int 12, <=> 12, read 8 among the largest sub-group), implicit_self_unresolved 199 (core mrblib self calls; to_enum is 66 across the whole census), traced_class_no_direct_target 44 and chain/runtime_class 22. Mostly inherent: user objects, procs and IO-like receivers. This category did not shrink (469 to 475) while the others did, so it is now a fifth of what is left. Unresolved receiver origins across all diag sites: send_result 90, incoming_or_unwritten_register 49, constant_lookup 42, indexed_result 17, captured_upvar 11.

6. Numeric tag guard (121). 102 are [] behind an Integer-tag test; the else sends by name.

7. No guard nearby (95). to_f 20, [] 18, abs 17, === 11, include? 10, respond_to? 7. Down from 162.

8. CLOSED_WORLD kept: singleton_definer (91). width 47, height 33: some definer is a singleton method, so any instance might carry one.

9. CLOSED_WORLD kept: dynamic_install (29). A runtime definition site can install a method of that name (ADR 0288 resolves only literal names).

Block and funcall sites

mrb_funcall_with_block has 417 sites (448 before) and there are 462 BLOCK_FALLBACK markers. The mrb_funcall* sites in bodies are 30, mostly literal-sized splat forwarding. The census script does not break block sites down by name; see the ADR 0325 section below for the block-arm measurements.

Follow-up: the exact receiver reaches every arm wrapper (ADR 0301)

Measured at master c7ff3846 (ADR 0296 class pools and ADR 0297 unlisted-class arms merged), the same method, before and after ADR 0301. The estimates above were written before those two landed; the table is the measured effect of one more change on top of them.

Measure Before After Delta
bc2cpp_send call sites in generated bodies 3,294 3,138 -156
bc2cpp_send calls held in helpers 24 24 0
calls into bc2cpp_slow_* 3,542 3,542 0
calls into bc2cpp_eqq 110 110 0
calls into bc2cpp_getidx / getidx0 / setidx 2,417 2,397 -20
mrb_funcall_with_block + body mrb_funcall* 476 476 0
Sites that can reach by-name dispatch 9,839 9,663 -176 (-1.8%)
bc2cpp_nomethod sites (errors) 4,435 4,399 -36

No helper gained a caller, so all 176 are removals. By name: size 46, empty? 30, [] 13 (net), z= 13, last 11, x= 7, first 6, y= 5, keys 4, length 4, the rest 17. Of the category table above, "core tag chain else" is 1,368 to 1,205 (receiver not an ivar 1,091 to 971, receiver an ivar 277 to 234), rgss_native_exact_class_else 567 to 517, and core_or_native 281 to 278; the other rows are unchanged except numeric_tag_guard (82 to 111) and no_guard_nearby (162 to 166), which gained the sends that sit next to a newly inlined INDEX_EXACT fast path.

What the remaining population looks like, from a per-site hook that printed the receiver's class set and producer (the exact-class flow of ADR 0289/0296) at the registered-expression chains (832 sites, the size/empty?/first family): 90 had a proof already (now consumed); of the other 742, the register came from a call result 245, an ivar 160, an incoming argument 128, a GETIDX element 62, a constant 44, a captured local 27, a literal joined with another value 21. For [a, b].max / .min (62 sites) the receiver is exact and the elements are not: 1 site has all-Integer elements. For core_or_native (157 sites with a flow context) 13 had an exact receiver, the others an ivar 52, an argument 41, a call result 31, an element 14. So the estimate above for item 1 ("copy-propagated receiver classes", 300-450 sites) overstated what was left to propagate: copies, return values and arguments were already followed, and the unknown roots are what is left.

Follow-up: RGSS native result facts (ADR 0302)

Items 4 (RGSS arguments and receivers) and 6 (singleton_definer) above were attacked with audited result facts for the RGSS getters (Bitmap#width/height/rect/text_size, Rect#x/y/width/height, Color/Tone components), the exact-receiver call of a zero-argument native wrapper, and an instance-receiver proof that lets a .singleton definer be ignored. Same method as above, master c7ff3846 as the base (bc2cpp_send 3,224 here because the base was re-measured on that commit):

Measure Before After Delta
bc2cpp_send call sites in generated bodies 3,224 3,186 -38
bc2cpp_slow_* calls in bodies 3,542 3,523 -19
NUMERIC_OPERAND_PROOF arms 361 373 +12
NATIVE_EXACT_DIRECT calls 80 114 +34
CLOSED_WORLD kept: singleton_definer 167 130 -37

The estimate of 100-168 for item 6 was too high: only receivers the exact-class flow can name (exact Bitmap/Rect, nil-or-RPG2k::Window) qualify, and 130 sites still have an unnamed receiver. The width count stays high because Game::Map#width and the Window readers are data-driven.

Follow-up: block arms reach nested and re-counted sends (ADR 0310)

The block and funcall sites above were attacked at the engine's literal-block sends. The census does not split mrb_funcall_with_block by owner, so the numbers below are the generated functions whose names start with Game_/RPG2k (the engine, 356 literal-block sends) and everything else. Master 247a34e4 as the base, the same tree with BC2CPP_BLOCK_ARM_REACH=0 as the control (byte-identical to master):

Measure Before After Delta
engine literal-block sends, direct call with the block 277 307 +30
engine literal-block sends, dynamic dispatch only 68 38 -30
engine &expr block sends (EXPLICIT_BLOCK_ARG) 11 11 0
all owners, direct / dynamic only 370 / 77 401 / 46 +31 / -31
mrb_funcall_with_block sites, engine 328 287 -41
mrb_funcall_with_block sites, everything else 120 120 0
bc2cpp_send, mrb_funcall*, bc2cpp_slow_* callers unchanged unchanged 0

All 41 funcall sites are removals (dead else of a proven, yield-free arm); the other 20 converted sends are relocations: they keep a by-name else for receivers that are not exactly an Array, Hash or Range. Why the remaining 38 engine sends stay dynamic only: Array.new(n) { } 10, RGSS::Profiler.section 9, loop 6, File.open 4, reduce 4, index 3, each_line/each_char 2, and nested sends the proof still refuses 0 (all 25 were taken). The &expr ones are reject(&:sym) 6, select(&:sym) 4 and each(&blk) 1.

Follow-up: where the container receivers come from (ADR 0308)

The two core_tag_chain_else categories counted for the rpg2k gem only (functions named RPG2k* or Game*; every gem together is 971 and 234) at master e1c671e4: receiver_other 714 and receiver_is_ivar 204. A temporary hook at compile_insn printed the exact-class flow's mask for each receiver register and its reaching definitions. The producer of the receiver, 913 sites with a flow context:

producer sites
@ivar read 214
call result (SEND0 130, SSEND0 63, SENDB 31, SSEND 23, SEND 12, AREF 10) 269
GETIDX element of another container 124 (+28 joined with a literal)
incoming argument 90
reaching definitions refused 67
captured local (GETUPVAR) 41
constant 34
rest 74

Why the 215 ivar-rooted sites have no usable class pool (first store that drops the pool, all stores at the final fixpoint): a constructor or setter argument 47 (Scene::Base#initialize @parent = parent alone is 26), the result of compact/map/select/uniq/dup/Array.new on an unknown receiver 63 (+29 reading it through Party#actors), a user method result 14, an element of another container 12, several of those 24, a numeric ivar 8, an attr_writer or a Symbol/String spelling of the name 26, and 19 sites whose pool is exact but whose consumer (ARRAY_PUSH) has no exact arm. There is no single missing rule; the argument-shaped rows cannot be pooled because any x.new(...) with a non-constant receiver may construct any class. Optimistic experiments (unsound, not shipped) measured the ceiling of two obvious rules: core container result classes on exact receivers move the categories by 4 of 1,205 sites, Array.new/Hash.new results by 12 with 34 relocations into INDEX_EXACT Array else arms.

What did move, and by how much (sites that can reach by-name dispatch for the rpg2k gem, the sum of bc2cpp_send and the getidx/setidx/slow_*/eqq callers): ADR 0308's captured-local class flow, 7,625 to 7,558 (-67), of which 41 are hash index arms with no else, 9 are Array index arms that relocated their by-name [] into an inline else (already netted out), and 11 are slow-path numeric helpers. The two named categories moved by 7 (receiver_other 714 to 707) and 0.

Reproducing the numbers: a worktree checkout has empty 3rd/* submodule directories, so 3rd/mruby is empty, the native scan sees no mruby core and the closed world refuses most proofs. bc2cpp_send is then about 8,000 instead of 3,030 and every category above is wrong; check the first line of the census before trusting a run.

Follow-up: exact RGSS native receivers (ADR 0307)

The rgss_native_exact_class_else category (463 of the 2,205 RPG2k_*/Game_* sites at master 359b9abd) grouped by why the receiver class was unproven, from a temporary hook that printed the exact-class flow's class set, the producer of the receiver register and the ivar pool state at each site:

Root cause Sites
Bitmap.new(w, h): the constructor arm tests both Integer tags and dispatches for the String form 120
the flow already proves one class (74) or nil plus one class (90); the older guarded arms ignored it 164
call result 41, ivar pool dropped or structural 49, captured local 33, argument 21, GETIDX element 21, nil-written register 9 174
Hash#clear, no diagnostic row 5

EXACT_NATIVE_WRAPPER (ADR 0307) takes the 164. Same method, kill switch against default:

Measure Before After Delta
bc2cpp_send call sites in generated bodies 3,030 2,851 -179
of them in RPG2k_*/Game_* 2,205 2,047 -158
rgss_native_exact_class_else, rpg2k only / all gems 463 / 516 305 / 337 -158 / -179
bc2cpp_nil_receiver calls (cold nil arm of a new NILABLE_RECEIVER site) 787 868 +81
bc2cpp_nomethod sites 4,409 4,365 -44

98 of the 179 are plain removals; 81 moved the by-name path into the nil arm of bc2cpp_nil_receiver, so the net on sites that can reach by-name dispatch is -98. The remaining 305 are the 120 constructor sites (an Integer proof for the arguments, not built), the 174 unprovable receivers and 12 sites the flow proves but a Fixnum argument probably keeps.

Follow-up: exact receivers reach the compiled core (ADR 0314)

Measured on the wio closed world at 43031b24, shipped pass. A send with no block whose receiver is an exact Array/Hash (ADR 0280) now calls the compiled core body when the body cannot suspend a Fiber.

Before After
cached dynamic sites 3,240 3,159
bc2cpp_send sites 2,815 2,734
engine bc2cpp_send sites 2,377 2,296
CORE_EXACT_DIRECT sites 0 81 (max 46, min 31, uniq 3, fetch 1)

All 81 are removals. What is left of the core_or_native kept-else category (278 engine sites) is a receiver proof problem: 14 of its sites have an exact receiver. BC2CPP_CORE_EXTEND=0 restores the earlier output byte for byte. The ADR also holds the table of all 220 core-source methods and the Fiber-guard measurements.

Ranking by executed count

The counts above weigh a start-up site like a frame-loop site. The tables below came from SITE_PROFILE, an opt-in counter that rewrote every by-name send and mrb_funcall* call in the generated code to count its executions, and from dynamic_site_census.rb --rank, which ranked the counts. Neither is in the tree any more (BC2CPP_SITE_PROFILE does nothing), so these results are historical and the runs cannot be repeated as written. A hit was one call that reached the site: for the else arm of a guard chain that is a guard miss, and a site in a shared helper counted all of its callers together.

First measurement: optcarrot, 180 frames

Run on the master tree plus this change, Lan_Master.nes, checksum 59662 (equal to CRuby's). The optcarrot build is not the wio closed world: it is optcarrot's own closed world (326 compiled methods, 1,083 instrumented sites), so these numbers say where the probe's leftover dispatch runs, not where the game's does. 7,818,761 by-name dispatches in 180 frames; 180 of 1,083 sites ran at all; the top 4 sites are 84.9% of the hits.

# Hits Share Site (generated function) Why it stayed dynamic Lever that would remove it
1 2,667,211 34.1% [] in helper bc2cpp_getidx the helper's by-name tail, reached when the receiver is not an exact Array/Hash/String with a fitting key Probably Integer#[] (optcarrot's bit-read shim), not confirmed per caller. An Integer-receiver arm in the helper, or an Integer fact at the callers (NUMERIC_OPERAND_PROOF)
2 1,773,013 22.7% @conf.loglevel, CPU#run dynamic_no_registered_definition: Config#loglevel comes from attr_reader id inside an each_value loop, so the registry sees no definer Enumerate the names a constant-driven attr_reader/define_method loop installs (ADR 0288 handles literal names only), plus an exact Config class for @conf; then ivar_accessor_call_code
3 1,339,857 17.1% @bg_pixels.rotate! , PPU#load_tiles one definer (Array#rotate!, core mrblib), receiver class unresolved Class pool for @bg_pixels ([0] * 16: needs the class of an Array#* result, ADR 0289/0296), then a direct call to the compiled core body (ADR 0264)
4-5 856,086 each 10.9% each send(mode, ...) / send(instr), CPU#r_op send with a computed name A new proof: the names come from the constant DISPATCH table, so expand the send into a switch of direct calls. ADR 0279 only makes the proofs see such a send
6-7 129,833; 51,185 1.7%; 0.7% @bits.even?, APU::Noise#sample one definer, @bits class unresolved Integer ivar typing (numeric ivars, ADR 0279) for @bits
8-10 22,471 each 0.3% each send ×3, CPU#w_op computed-name send as 4-5
11 17,465 0.2% @store[addr][addr, value], CPU#store no guard: the receiver is an element of @store, an Array of Method/Proc objects An exact Method/Proc arm for [], or an element-class fact for @store (array element layout)
12-14 6,392 each 0.1% each send ×3, CPU#rw_op computed-name send as 4-5
15 6,230 0.1% ~ , CPU#_sbc one definer, receiver class unresolved Integer proof for the operand
16 5,272 0.1% run, PPU#sync chain/runtime_class Inherent: the receiver class is chosen at run time
17-18 3,087; 3,072 <0.1% *, round in bc2cpp_slow_mul_f / bc2cpp_slow_round the numeric slow-path helpers' by-name tail None worth taking: Float and non-numeric operands
19-22 3,072 each <0.1% polar, conjugate, real, sort in Palette.nestopia_palette blocks no bytecode definer (Complex/native), start-up palette build Cold: runs once per palette entry
23 2,477 <0.1% each with a block, PPU#poke_2007 BLOCK_FALLBACK BLOCK_CORE_DIRECT / yield-free block proof (ADR 0283)
24-30 1,498 down to 672 <0.1% bc2cpp_getidx0 [], uniq!, polar, map, bc2cpp_slow_add_f +, include?, polar helpers and start-up palette code Cold, or as 1 and 17

The lever column is an analysis of the generated C++ and the optcarrot source, not a measurement: nothing was built to confirm that a proof removes a site. Rows 1 and 3-15 are the sites worth a proof; the sites after row 15 together run 35,206 times, 0.45% of the hits.

After ADR 0304 (loop-installed accessors)

Same workload and tree plus LoopInstallers: 6,025,773 by-name dispatches (-1,792,988, -22.9%), 158 of 1,085 sites ran. Row 2 (@conf.loglevel) is gone from the table (1,773,013 to 0; the rest of the category, mostly Config readers used at start-up, 1,784,121 to 188). @conf.loglevel is now a runtime-class chain with a direct ivar read (POLY_SMALL_N), not an exact-class arm: @conf is assigned from a constructor argument, which the class pools do not follow (ADR 0295). The ranking is now [] in bc2cpp_getidx 44.3%, rotate! 22.2%, the send sites 29.9%.

Row 1 resolved (ADR 0305): a receiver histogram at the tail showed 2,552,721 Integer (the probe's Integer#[] shim; mruby core has none), 105,005 IdentityHashShim and 9,485 Method. Feeding the shim to bc2cpp gives the helper an exact-class Integer arm; re-measured total 5,266,040 hits (-32.6%), helper tail 114,490, same 1,083 sites, checksum 59662.

The ranker also groups the hits by reason category (a start-up palette site and a frame-loop site share one): shared_helper 34.2%, poly_diag single definer with implicit self (all send) 23.0%, poly_diag no definer with unresolved receiver (loglevel, palette) 22.8%, poly_diag single definer with unresolved receiver (rotate!, even?) 19.5%, everything else 0.5%. By contrast, the core-tag-chain and owner-chain categories, the largest in the static tables above (511 instrumented sites here), ran 676 times in total: this workload does not exercise the shapes that make up most of the static count.

Not run

  • The game smoke. It needs the SDL engine and an RPG2000/2003 project (data/Nepheshel206beta, data/mtf-meido-action, fetched by scripts/download-*.bash) and runs in CI (rpg2k_boot_check.bash). Neither the SDL build nor the games were available where this was written, so there is no game ranking, and the wio closed-world sites above have no executed counts.
  • Per-caller counts for helper sites, and any other workload (the MV and wio smokes).

Element reads of mutable containers (ADR 0312)

BC2CPP_ELEMENT_REPORT=<tsv> (tools/bc2cpp/element_site_report.rb) writes one row per instruction that can still reach a by-name call, with the origin of its receiver, and one row per write into a container ivar with the class set of the value; scripts/bc2cpp/element_site_report.rb <tsv> aggregates it:

BC2CPP_ELEMENT_REPORT=/tmp/elements.tsv MRBC=<host mrbc> ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/element_site_report.rb /tmp/elements.tsv

On the wio closed world (engine owners): 15,349 sites can reach by-name dispatch, 1,151 read an element, 380 of those an element of an ivar container, and only 28 of those 380 sit on an ivar whose every creation and write has a known class set (ceiling, before any alias check). The element class of a mutable container is therefore limited by the class of the values stored, which are mostly method results and parameters, not by aliasing; ADR 0312 records why nothing was built. The report changes no generated code.

Kept else arms and interface tables (ADR 0315)

BC2CPP_ITAB_REPORT=<tsv> (tools/bc2cpp/interface_table_report.rb) writes one row per explicit-receiver send that has a guard chain (family, the else arm of the final code, every ClosedWorld#refusal gate the name fails, the proven receiver classes or the flow mask that fell short, the receiver's origin, the chain's classes, what each cell of a proven set would be, the name's native registrations) and <tsv>.names (one row per method name); scripts/bc2cpp/interface_table_report.rb <tsv> [owner-regexp] aggregates them:

BC2CPP_ITAB_REPORT=/tmp/itab.tsv MRBC=<host mrbc> ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/interface_table_report.rb /tmp/itab.tsv '\A(RPG2k|Game)'

On the wio closed world (master 43031b24): 5,730 sends have a chain, 4,377 end in bc2cpp_nomethod, 783 have no dispatch left, and 570 still dispatch by name. 507 of those (89%) have a receiver class set the exact-class flow does not prove, so no per-class table can exist; of the 63 with a proven set, at most 42 (@window.update, @interpreter.update, one start) could lose the dispatch, and only with three new proofs together (a definer inside class << <class> is a class-object definer, per-class native/outside-Ruby resolution, a cell check against the set rather than the name's whole definer set). ADR 0315 records why nothing was built, the annotation inventory (and why an RBS-style form does not fit), and the per-name method-set sizes (94% of the polymorphic names have four or fewer Ruby implementers). ADR 0328 later builds the tables anyway, opt-in and for source size and lookup shape rather than else-arm removal. ADR 0296's "372 kept by name" rows are sites with no dispatch left in the final code. The report changes no generated code (shipped.cxx is byte-identical with it on).

Follow-up: which producer leaves a receiver unproven (ADR 0309)

The register_copy and direct_call_result origins above are a text heuristic. The origin= on a POLY_DIAG line is join-aware: at a join it names every producer that reaches the receiver, as join:a.b (sorted), so write_loadnil alone means one nil producer. A receiver the IR refuses (a rescue region) is unproven.<walk label>, which is not a complete set. The label does not change dispatch; receiver_class_unresolved sites stay as they were. To see the producing method, build with a tag on every by-name line and join the shipped sites to it:

MRBC=<host mrbc> BC2CPP_SEND_ROOT_REPORT=/tmp/keep/rows.tsv BC2CPP_COVERAGE_KEEP_DIR=/tmp/keep \
  ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/send_root_report.rb /tmp/keep [FUNCTION_PREFIX_REGEX]

The report is for ranking (the tag changes the generated text). Measured on master 2b317417, the 2,196 shipped rpg2k sites have these receiver producers: a call result 504, an ivar 503, an incoming argument 303, a GETIDX element 203, a constant 166, an Array literal 107, a captured local 81, 202 not attributed. Of the 503 ivar receivers 225 have no class pool, and the first blocker of 99 of those is a constructor argument (initialize is excluded from argument pools, ADR 0295); 205 have one and still go by name for the consumer's own reasons. The call results are mostly project getters over an unpooled slot (about 150), names a foreign Ruby or native source spells (about 110) and Bitmap.new/Sprite.new (69). ADR 0309 takes the readers (attr_reader returns its slot's class set) and the exact-Array arms (ARRAY_PUSH, the TYPED call of a core body): 63 fewer rpg2k sites and 256 fewer that can reach by-name dispatch (7,605 to 7,349), all removals. No remaining cause is above about 5% of the sites.

Caveats

  • The static tables above count source sites, not executions. A site in a cold scene weighs the same as one in the frame loop; the section "Ranking by executed count" holds the (historical) executed counts.
  • The marker is the nearest preceding family comment within 25 lines and can be a neighbour's; the exclusive categories use position and guard shape first so they do not depend on it.
  • The receiver origin is exact only where the table answers. Without BC2CPP_COVERAGE_ORIGIN_TABLE the census runs the old text walk, kept as the before-measurement. With the table, the origin is the single reaching definition of the receiver register (MOVE chains followed; see "Exact receiver origins" below). ambiguous, refused, untagged and not_a_register count as unknown, and so does a send whose defining instruction emits several producers (a fast path and its dispatch).
  • The unlisted_class_call reasons came from a one-run instrumentation that is not in the tree; they are per class and name, and the counts here map each site to its class's dominant reason.
  • Removal estimates are judgements. None was built or measured.
  • The baseline is a saved shipped.cxx from before the round, not a rebuild of the old commit; its 9,733 bc2cpp_send lines match the figure quoted for it.

Element classes of frozen tables (ADR 0306)

BC2CPP_FROZEN_TABLES=0 is the "before". On the merged tree it moved 6 numeric operator sites (see the ADR for the per-helper counts) and nothing else: element classes of mutable Arrays/Hashes in ivars and locals, the bulk of the unknown receiver roots, are not covered.

Numeric operand roots and tuple returns (ADR 0311)

The bc2cpp_slow_* rows of the tables above are guarded numeric operators whose operands NumericFlow could not prove. BC2CPP_NUMERIC_ROOTS=<file> MRBC=... ruby scripts/bc2cpp/coverage_report.rb writes one ROOT line per such operator of the shipped pass (owner, operator, class set per operand, the leaves it is computed from; the leaves are the reaching definitions since ADR 0509, and a refused query shows as unproven:<cause>), through tools/bc2cpp/codegen_numeric_roots.rb; the generated code is byte-identical with it on. On the branch point 247a34e4 the engine owners (RPG2k*, Game*) had 2,384 sites with an unproven + - * / < <= > >= operand. No root family is worth more than 13% even when forced to Integer (GETIDX results -321, size/length/count -140, hp/width/x-style getters -109, AREF -91, captured locals -38, native-spelled ivars -30). The sound slice built is AREF of a fixed-arity Array return: master 2b317417 3,040 engine helper calls to 2,999 (-41, removals), all in four methods because the other tuples' inputs are parameters, native-spelled ivars or Array elements.

Follow-up: constructor argument pools (ADR 0313)

Measured at master 43031b24, kill switch against default, same tree, 3rd/* populated:

Measure Before After Delta
bc2cpp_send call sites in generated bodies 2,815 2,814 -1
of them in RPG2k_*/Game_* 2,009 2,007 -2
generated callers of bc2cpp_getidx 2,095 2,070 -25
bc2cpp_slow_* lines, bc2cpp_eqq calls, bc2cpp_nil_receiver calls 3,498 / 110 / 877 same 0
bc2cpp_nomethod sites 4,360 4,368 +8

Removal, not relocation. The 675 new sends of the build are all visible (69 with no arguments, 301 on a class with a Ruby initialize, 298 on a native or core class, 7 rooted in a singleton or self.class, none unresolved); the "58 computed new sites" were constants inside rescue-covered methods that agreed_constant_name refuses. The Bitmap.new(w, h) Integer-tag sites do not come from constructor arguments: their w/h are failed numeric constants (LINE_H, SCREEN_W, TILE, FACE_SIZE), Rect/Bitmap native results, Array#max and Window#width.

Follow-up: where a value goes

The census above counts by-name sends; this one counts creation sites (LAMBDA, BLOCK, ARRAY, HASH, STRING, new) and asks whether the value leaves the frame that made it, with the shared escape analysis (BC2CPP_ESCAPE_REPORT=<tsv>, scripts/bc2cpp/escape_report.rb tsv shipped.cxx). Wio closed world, master 56b14771, shipped pass:

Measure Value
creation sites in methods that ship 4,114
LAMBDA sites / confined today / confined by the analysis / newly confined 3 / 1 / 1 / 0
BLOCK_FALLBACK regions / proven confined (callee set closed over receiver classes) 466 / 109
each sends the analysis cannot prove (5 of the 11 each definitions keep their block) 168 of 206
Arrays / Hashes / objects used only locally (stack candidates, count only) 46 / 44 / 3
constructors whose self does not leave 51 of 82
methods that newly compile with BLOCK_FALLBACK_PROVEN 1 (Array#combination)
shipped.cxx with BC2CPP_ESCAPE_ANALYSIS=0 against master byte-identical

Follow-up: receiver-class flow for block sends

Block sends on a receiver of one proven class were still judged by every definition of the name. Now a send whose receiver is exactly one class (the held tracked value, or a class set of one from the class flow) judges each callee of that class, and the callee's own calls on self resolve on that class too. Enumerable#map, max_by, sum, group_by, max, any?, each_with_index and the rest call self.each; that call was the by-name each set before, and five of its eleven definitions keep their block. The same self class also judges a held receiver's [:self_keep] summary, so Array#each's own length no longer enumerates every length. Only plain defs in a class or module body (not a Class.new { def }, a module_function copy, an installed body or a singleton one) are judged this way, and only for a placed class that is not a module (block_captures_as?, self_captures_as?, receiver_klasses in tools/bc2cpp/escape_analysis.rb).

Still "escapes": a receiver of unknown class or of two classes; a user map, each or subclass each that keeps its block; an Enumerable whose each keeps it; a block that stores the object or a closure over it; an object passed to an unknown callee. Covered by scripts/bc2cpp/escape_receiver_flow_check.rb (verdicts for the proven and refused shapes, and compiled against interpreted runs of the confined ones). The Array literal receiver of each and map is decided by the next follow-up.

Measure (base origin/master and this branch, the same census command in each tree) Base Branch
creation sites, all irep (6,581): confined 1,274 1,367
of those, escapes turned confined / confined turned escapes 93 / 0
send_block escapes, all irep 492 401
engine block sites confined, shipped (673) 288 377
engine array / hash used only locally (stack candidates, count only), shipped 54 / 44 55 / 45
shipped.cxx (425,016 lines) byte-identical after normalising the checkout path

The 93 are 91 blocks, one Array and one Hash. The shipped C++ does not change. The codegen effect is on names outside BLOCK_FALLBACK_UPVAR_SAFE_METHODS (max_by, min_by, sum, group_by, max): a fixture max_by block with captured locals now compiles as a BLOCK_FALLBACK with the frame's captured pointers, where the base emitted no fallback for it. The behaviour check runs those blocks compiled against the interpreted run.

A gap this change did not touch, found while writing the check: BLOCK_FALLBACK_UPVAR_SAFE_METHODS is a claim about every definition of a name. A user map that keeps its block (def map(&b); @b = b; self; end) still gets the pointer captures (mrb_cptr_value(M, &r2) in the generated code), so calling the kept block after the method returned reads the dead frame. The compiled run of such a program segfaults on origin/master too. The behaviour program of scripts/bc2cpp/escape_receiver_flow_check.rb has no such definition; its verdict program keeps them only for the verdict.

Follow-up: the block arm of a literal-block send

An Array literal receiver of each or map ([1, 2].each { }) was still "escapes" after the receiver-class flow: the no-block branch of Array#each returns to_enum(:each), which keeps self, and the analysis did not branch on the block's presence. Now a send whose block is the BLOCK made in its own frame (SENDB, or the implicit-self SSENDB) is judged with the block given:

  • literal_block_send? (tools/bc2cpp/escape_analysis.rb) proves the block is a Proc: the block register's only reaching definition is a BLOCK. A variable, &blk, an optional value or several writers keep the by-name verdict, and a SEND (no block) is never judged this way.
  • With the block given, a jump on the block slot's entry value (unless block) is decided, so the to_enum arm is not walked (block_test_successors). A jump with a handler edge, or a write to the slot from this frame or a nested block, is not decided. The same rule applies to the callee's returns_self?, so the result of the call is tracked only when the block path can return self.
  • In a self-tracked run the frame's self is an instance of the run's exact class, so a closure of that frame is judged on that class (frame_self_klass). This is what lets Enumerable#collect's own self.each { } resolve on Array#each rather than on every each.

Still "escapes": a no-block each (the Enumerator holds the array); a block that stores its argument, or the receiver itself, in an outer array; a block passed as &blk, which is not a literal block; an array passed to an unknown method. Covered by scripts/bc2cpp/array_literal_block_check.rb (the verdicts above, and compiled against interpreted runs of the confined each and map and of the no-block path).

Measure (base origin/master and this branch, the same census command in each tree) Base Branch
creation sites, all irep (6,581): confined 1,367 1,440
of those, escapes turned confined / confined turned escapes 73 / 0
the 73 turned confined: array literals, by first reason (send_receiver / send_argument) 67 / 6
array literal sites with send_receiver, all irep 223 67
of the 156 no longer send_receiver: confined / now escaping for a later reason 67 / 89
shipped methods (4,210 creation sites): engine array confined 55 128
shipped.cxx (413,906 lines) byte-identical after normalising the checkout path
dynamic_site_census.rb on that shipped.cxx (bc2cpp_send 835 in bodies, mrb_funcall_with_block 379) same same

The 89 keep escaping (they are not confined by this change): the walk now reaches their later reason (an argument, a return, an ivar store) instead of stopping at the to_enum arm. coverage_report.rb still exits 1 on both trees at its summary step (the known label mismatch); the escape TSV and shipped.cxx are written before it.

Follow-up: the class of a new site

BC2CPP_ESCAPE_REPORT gives each new send (SEND0/SEND/SENDB on new) the class it constructs, so the report can ask whether that class's initialize keeps self. The lookup took only a bare written name that the hierarchy tables declare exactly, so Rect.new inside class RPG2k::Window (the RGSS::Rect reached through Object's included module) and Scene::MapViewer.new inside RPG2k were refused. It now also takes the receiver the compiler proves to be one stable class constant (constant_object_owner, the proof CONSTRUCTOR_EXACT_CONSTANT uses), and refuses a name the closed world rebinds (Outer::Reset = Rebound after class Reset). The two must agree; a receiver neither proves stays unknown. Report only: creation is called without a class everywhere else, so no generated code changes (the unit fixture's generated output is byte-identical). Engine new sites (662), base 3c9a389 against this branch, same census run:

Object-kind verdict (engine) Before After
confined 0 1
initialize_unknown 570 255
initialize_keeps_self 71 269
other escape reasons 21 137

The 255 still unknown: Rect, Color, Tone and Table (229 sites) are bound by an assigned alias in mruby-rpgxp (Rect = RGSS::Rect), which the whole-program unique-name proof refuses; 11 StringIO sites are a native class the hierarchy tables do not place; 15 receivers are refused by the dataflow or come from an upvalue or an argument.

Follow-up: provable errors and post-call facts

BC2CPP_PROVABLE_ERROR_REPORT=<tsv> and BC2CPP_REFINE_REPORT=<tsv> (run with BC2CPP_CALL_FACTS=0 to measure the potential), aggregated by scripts/bc2cpp/refine_report.rb tsv [--list-bugs]. Wio closed world, master 147fe7e6:

Measure Value
provable errors in the three engine gems (nomethod, nil receiver, arity, operator, zero divide, undefined name) 0
explicit by-name sites / lines in the engine gems 2,154 / 2,225
kept-else sites / receiver unproven 438 / 378
by-name sites bounded to user classes by the forward interface / dead else 105 / 100 (64 kept else)
realized bc2cpp_send / kept else arms / bc2cpp_nomethod 2,666 -> 2,603 / 462 -> 399 / 4,318 -> 4,380
backward interface (unsound) upper bound, kept-else removals 114
NOMETHOD_REVIEWED keys: monomorphic / polymorphic / narrowed / native arms / untraced / candidate bug 1,774 / 567 / 289 / 58 / 252 / 1
POLY_SMALL_N chains with two classes on one definition 50 of 1,525 (177 of 8,906 compares)
shipped.cxx with BC2CPP_CALL_FACTS=0 against master byte-identical

Follow-up: per-class resolution for proven receiver sets (ADR 0323)

BC2CPP_NATIVE_ARMS_REPORT=<tsv> (tools/bc2cpp/native_arms_report.rb, aggregated by scripts/bc2cpp/native_arms_report.rb tsv [owner-regexp]) writes one row per by-name site with its receiver set S (proven by the exact-class flow or bounded by call facts, whatever the members), each member's cell kind and the gates that fail today. Wio closed world, master 7818f0f5, engine gems:

Measure Value
by-name sites / whose else still dispatches (kept marker 372, bare send 1,553) 2,009 / 1,925
dispatching sites with a bounded set (proven 163, call facts 107) / unproven 270 / 1,655
bounded sites: kept else / bare send 92 / 178
bounded sites a cell blocks (native with no entry, guarded :int entry, Ruby body not direct-callable) 186
bc2cpp_send / bc2cpp_nomethod, switch off -> on 2,513 -> 2,463 / 4,380 -> 4,441
by-name sites removed (update 42, Hash#delete 8) / new by-name sites 50 / 0
shipped.cxx with BC2CPP_NATIVE_CLASS_ARMS=0 against master byte-identical

Sites with an unproven receiver set cannot be fixed by these proofs. The three proofs of ADR 0315 (class-object definers, per-class native/outside resolution, a cell check against S) remove nothing alone; together they remove the 42 update sites of the class << Graphics probe, and FLOW_CORE_DIRECT the 8 Hash#delete sites of a flow-proven Hash.

Follow-up: numeric constants and native :int arguments (ADR 0318)

BC2CPP_NATIVE_INT_ARGS=<tsv> writes one NINT line per :int argument of a native entry point (Bitmap.new, exact Sprite#x=-style calls) the Fixnum proof does not cover: the site, the writer of the register and the leaves behind it; BC2CPP_NUMERIC_CONSTANTS_REPORT=<tsv> writes each constant name with a definition and no interval, and why. Wio closed world, master b1ab8310, 3rd/* populated, kill switch BC2CPP_NUMERIC_CONSTANTS=0 against default:

Measure Before After Delta
bc2cpp_send call sites 2,607 2,538 -69
of them in RPG2k_*/Game_* 1,779 1,714 -65
Bitmap.new sites without a tag test and else (of 115) 0 65 +65
bc2cpp_getidx, bc2cpp_slow_*, bc2cpp_eqq, bc2cpp_nil_receiver, bc2cpp_nomethod 2,033 / 3,355 / 111 / 892 / 4,386 same 0
constants with a proven Fixnum interval 693 Fixnum constants 725

Removal, not relocation. The 115 Bitmap.new sites never asked the Fixnum proof (29 are removed by asking it); 36 more by an interval for + - * / of constants. Of the 50 left, 17 are the String file-load form, 11 Array#size/max results, 8 user readers, 4 Bitmap#width/height, 4 parameters. flowfail of a constant means NumericFlow does not model a class body, not that the constant is not an Integer; LINE_H, SCREEN_W, TILE and FACE_SIZE were Fixnum constants all along. shipped.cxx with the switch off is byte-identical to a clean master build.

Follow-up: interval operands for the Fixnum proof's consumers (ADR 0326)

BC2CPP_NUMERIC_INTERVALS=0 is byte-identical to master. Wio closed world, master 7818f0f5, 3rd/* populated, default against master:

Measure master default Delta
bc2cpp_send call sites 2,538 2,496 -42 (Array []/[]= else)
bc2cpp_slow_* callers (add_f 720, sub_f 479, mul_f 451 -> 719 / 473 / 441) 3,343 3,326 -17
mrb_fixnum_p( / mrb_integer_p( tests 3,597 / 3,822 3,444 / 3,510 -153 / -312
arms proven (operands proven Fixnum) / proven Array index sites 388 / 0 556 / 42 +168 / +42
bc2cpp_getidx, bc2cpp_setidx, bc2cpp_nomethod 2,066 / 210 / 4,386 same 0

Follow-up: INTEGER_CONSTANT_PROOF soundness (ADR 0324)

Two latent holes in the older proof are closed: a native mrb_define_const/mrb_const_set of a bare name now withdraws it (the old scan matched no name), and a SETCONST that a jump lands on (X = c || 1) is no longer an Integer definition. Wio closed world, master 7818f0f5, 3rd/* populated, master against the fix:

Measure master fix
bc2cpp_send / bc2cpp_slow_* / bc2cpp_getidx / mrb_fixnum_p( / bc2cpp_nomethod 2,538 / 3,343 / 2,066 / 3,597 / 4,386 same
Integer constants / with an exact value 693 / 470 691 / 468 (MAX, MIN)
shipped.cxx digit-masked diff 28 lines: three symbols, two inlined reads of Game::Variables::MAX/MIN

MAX/MIN collide with the native Float::MAX/MIN (mruby-numeric-ext); the two engine reads (game.rb:1521-1522) resolve to the Integers, so behaviour did not differ.

Follow-up: EXT prefixes folded into their instruction (ADR 0320)

Wio closed world, master cd86085f, same tree with BC2CPP_EXT_PREFIX=0 (byte-identical to master) against the default. Three ireps carry a prefix (class bodies of LCF::Schema, RPG2k::Interpreter, RPG2k::Scene::Map: 635 EXT2, 9 EXT1).

Measure off on
constant pools / argument pools 658 / 110 796 / 115
bc2cpp_send in generated bodies / with guarded fallbacks 2,603 / 3,028 2,582 / 3,006
INDEX_EXACT arms / NUMERIC_OPERAND_PROOF arms removed 977 / 423 1,000 / 427
bc2cpp_nomethod sites / ivar pools / frozen table shapes 4,380 / 187 / 49 4,380 / 187 / 49

Baseline after the bc2cpp ADRs 0307 onward (master cd86085f, 2026-10-02)

One tree, one set of numbers: the eleven PRs of bc2cpp ADRs 0307 onward each measured their delta on a different master, so their deltas overlap. Full tables (totals per scope, marginal contribution of every kill switch, receiver origins, why-kept ranking, next levers, CI shard timing, tool problems) are in docs/bc2cpp-baseline-2026-10-02.md. Wio closed world, 3rd/* populated, default switches:

Measure all gems rpg2k + Game
bc2cpp_send / mrb_funcall* / mrb_funcall_with_block 2,603 / 28 / 401 1,800 / 0 / 277
bc2cpp_nomethod / bc2cpp_nil_receiver 4,380 / 890 4,274 / 837
slow_* / getidx / setidx callers 3,314 / 2,069 / 209 2,852 / 1,955 / 184
kept-else sites (engine gems, refine report) 375
by-name sites with a guard chain still dispatching (rpg2k) 453 (419 kept else, 34 send)

By-name bc2cpp_send sites each landed feature removes on this tree (switch off minus master, all gems): EXACT_NATIVE_WRAPPERS 209, CORE_EXTEND 81, CALL_FACTS 63, CAPTURED_LOCAL_CLASS 62, EXACT_CORE_ARMS 56, RETURN_ACCESSORS 13, CONSTRUCTOR_POOLS 1; BLOCK_ARM_REACH removes 48 mrb_funcall_with_block sites (and BLOCK_CORE_DIRECT=0 gives the identical counts, so the 48 depend on it); ESCAPE_ANALYSIS and TUPLE_RETURNS do not change the by-name count (TUPLE_RETURNS removes 41 slow_* callers). The core-tables CI shard is 29.7 minutes on this master, not 24.5.

Block sends: what keeps the dynamic line (ADR 0325, master 7818f0f5, nothing built)

BC2CPP_BLOCK_SEND_REPORT=<tsv> writes one row per block send and scripts/bc2cpp/block_send_report.rb <tsv> [--gem GEM] aggregates it (mruby-rpg2k is 352 block sends, 282 with a by-name line; the scope is by gem, the baseline's 277 is by function-name prefix). Wio closed world, 3rd/* populated.

Shape (rpg2k) Sites
exact_arms: class tests, dynamic else, receiver not proven 227
removed: proven class, the compiled call alone 42
dynamic: no compiled core callee (new 10, section 9, loop 6, open 4, reduce 4, index 3, 2 String iterators) 38
mono_direct: one resolved call (2 keep a chain else) 30
explicit: &expr 12
proven_guarded: proven class, block without a direct entry 3

Every literal block is yield-free and every arm body relaxable, so 215 of the 227 exact_arms sites become the compiled call alone once the receiver class is proven. Receiver of the 282 by-name sites: exact flow 4, post-call facts a usable user-class set 0 (21 core/native-bounded, 5 unbounded), no earlier call 252. Producer: call result 76, x || [] merge 49, GETIDX element 47, incoming argument 37, constant 20, ivar 16.

Lever ceilings in the engine gems (rpg2k / lcf / rgss): SENDB facts 0; Array.new(n) { } 10 / 1 / 0 (all Array.new); direct entry for break/return blocks 3 / 0 / 5; Hash#delete 6 / 2 / 0 (a flow-proven receiver that CORE_EXACT_DIRECT does not read) and Array#delete 2 / 0 / 0 (no compiled body); sum 29, below the cutoff of 30, so nothing was built.

Proven-error arms: are they reachable? (ADR 0330, master 49f39416, nothing built)

BC2CPP_DEAD_ARM_REPORT=<tsv> writes one row per bc2cpp_nomethod / bc2cpp_nil_receiver arm (plus partial_miss, const_unresolved, arity_* rows) and scripts/bc2cpp/dead_arm_report.rb <tsv> [--lcov lcov.info] [--list] aggregates it. The shipped C++ is byte-identical with the report on. Arms of the engine gems and core, by exclusive class:

Class Arms
(a) the else of a send with live arms 2,739
(b) the only arm of its send 0
(c) nil path, live, unguarded 822
(d) the method body is not callable by name 86
(e) guarded: probed 1,166, rescue 417, hint 76, selfset 29 1,688

The 822 (c) arms are 647 ivar reads (81 scene-lifecycle clusters: set in start/build_*, reset in dispose/close_*; Battle#@ui alone is 379), 152 database chunk or element reads, 19 parameters and 4 others. Read by hand, none is a latent bug; 88 partial_miss rows are one pooling imprecision (Menu#@message Hash against Map#@message). Provable errors (sole arms, no class answers, arity_all, operators, nil exactly) are 0, so making them compile errors removes nothing; the 14 const_unresolved rows are desktop-only constants behind rescue NameError.

Follow-up: Hash#delete flow-core coverage (ADR 0327)

The eight Hash#delete sends above (six RPG2k, two LCF) are removed by FLOW_CORE_DIRECT (ADR 0323). ADR 0327 adds no generator change; it adds pooled Hash delete/fetch fixtures and a mutant on the early return to the core-exact check. See ADR 0327.

Receiver class proofs: what keeps the unproven receivers (ADR 0331, master eed615c7, nothing built)

BC2CPP_RECEIVER_PROOF_REPORT=<tsv> writes one row per engine explicit-receiver send that still holds a by-name line, with the source of its receiver and what happens to the line when the receiver is forced to a class set (a forked recompile of the one send, so the output is byte-identical); scripts/bc2cpp/receiver_proof_report.rb <tsv> aggregates it. 1,970 sends, 187 with a proven set, 1,783 unproven.

Source (unproven) Sites Own set removes Floor removes (every answering class, nil allowed)
call result 491 110 71
ivar 322 8 66
argument 321 0 53
GETIDX element 234 - 6
constant 164 - 0
other 119 - 3
x || [] merge 85 75 1
captured local 47 - 4

Roots: 235 ivar sites read a pool dropped by an unmodelled store (a parameter, or a map/select/dup result), 54 an ivar a native spells (@contents 44), 173 parameters are not pool candidates, 147 have a dropped pool, 207 call results go through an ivar accessor, 136 through a native result, and per-class resolution of the callee completes 0 sites. A prototype (h.keys and Graphics.snap_to_bitmap as audited native result classes, and a <native> placeholder of a name no linked source defines dropped from the return join) removed 22 by-name sends against the cutoff of 30, so nothing was built; the lever left is a call-graph audit of the RGSS natives' self discipline for @contents (about 70 floor-freed sites).

Follow-up: native RGSS ivar families (ADR 0332)

The native Window call graph accesses @contents and @cursor_rect only on its Window receiver's self. Pinned native sources and a scan for outside helper callers let the compiler keep those names poisoned for the RGSS::Window family while pooling unrelated scene slots. The @viewport writer audit similarly scopes poisoning to RGSS::Sprite/Plane/Tilemap/Window, freeing RPG2k::Window's own Ruby-managed viewport. Unknown native setter values remain unknown.

On master 4bdd9492, switch off against on on one tree, the Wio shipped census goes from 2,924 to 2,881 cached by-name sites. All 43 removals belong to engine methods; 39 nil-receiver helper calls and one no-method helper call are added, while four obsolete no-method helpers in RPG2k::Window disappear (net -3). Class ivar pools grow from 185 to 187 and argument pools from 115 to 117. With BC2CPP_NATIVE_IVAR_SCOPES=0, the shipped C++ is byte-identical to master. Source changes, new outside callers or other outside spellings withdraw the scope proof. See ADR 0332.

Follow-up: native class results and absent placeholders (ADR 0333)

Audited native keys, values, bytes, split, members, to_a and parameters contribute Array results to Ruby/native return joins. Graphics.snap_to_bitmap contributes Bitmap or nil. A placeholder with no linked native definition no longer spoils a fully visible return join. The dispatch registry and original calls remain.

On master 43d4d462, the Wio census falls from 2,881 to 2,865 cached sites: native facts alone remove nine, absent placeholders alone seven. Class pools become 188 ivar and 120 argument pools; INDEX_EXACT arms grow from 1,000 to 1,025. Nil-receiver helpers remain at 918, while four obsolete reviewed dispose fallbacks disappear. Both new switches off reproduce master byte for byte. Together with ADR 0332, the measured gain is 59 cached sites.

Shared-name argument pools and Window width/height ivar scopes each measured zero further removals. Native setter caller completeness and dynamic setter names still block the contents/bitmap slice. A partial to_s audit remains blocked by linked definitions and aliases and is not shipped. The floor counts above overlap and are not guaranteed achievable gains. See ADR 0333 for the contracts and rejected candidates.

Follow-up: native String results (ADR 0334)

The linked native to_s audit now includes Fiber and the core Struct alias to its unique native inspect entry. String subclass freedom and all compiled Ruby returns remain part of the proof; native Onig contributes nil as well as String. Outside Ruby definitions, unknown aliases/installers and native replacements withdraw it.

On master 98914faf, the Wio census falls another 20 sites, from 2,865 to 2,845. Class pools grow from 188/120 ivar/argument pools to 189/136; nil-receiver helpers grow from 918 to 921. Reviewed error fallbacks remain unchanged. BC2CPP_NATIVE_STRING_RESULTS=0 reproduces master byte for byte. The three follow-ups now remove 79 cached sites from the original 2,924. Native setter inputs and collection element classes still need independent proofs. See ADR 0334.

Follow-up: native copy and collection results (ADR 0335)

Pinned native dup preserves known receiver class bits. Exact Array compact and join results require pinned allocation helpers and override-safe core lookup. The Wio census drops from 2,845 to 2,844 cached sends; all four receiver-proof follow-ups remove 80 sites from 2,924. Element classes remain unknown. The collection kill switch reproduces ADR 0334 shipped C++ byte for byte. See ADR 0335.

Follow-up: name-wide native collection transformations (ADR 0336)

Native compact, flatten and __uniq can contribute Array results, and audited Array/File join implementations contribute String results when File.join cannot preserve a String subclass. The linked-source audit can admit these facts through the broader foreign-name filter; compiled Ruby returns still join them, and aliases and uncompiled linked Ruby definitions remain refused.

The targeted unknown-input fixtures gain proofs, but the full shipped Wio C++ is byte-identical with the new switch on or off: 2,844 cached sends and the same pools and helper counts. The reduction from ADRs 0332–0335 remains 80 sites. This extends supported contracts without claiming additional census or speed savings. See ADR 0336.

Follow-up: native setter inputs (ADR 0337)

The setter report finds 62 named contents= candidates with Bitmap input facts (six unresolved receivers), and 31 bitmap= candidates: 23 Bitmap, one Bitmap-or-nil, and seven unknown inputs. The seven inputs are Graphics.transition, Ruby Window's contents= forwarding parameter and five battle sprite producers. Computed names remain present; these named candidates do not prove native caller completeness.

An audited bitmap writer scope isolates Sprite/Plane stores from unrelated Ruby fields. The current shipped Wio output remains byte-identical to the parent, with 2,844 cached sends and the same pools/helper counts. The report itself also changes no C++. See native setter inputs and ADR 0337.

Core Ruby collection returns (ADR 0338)

Actual core bytecode can now prove collection results for known receiver and block contexts. Literal caller blocks with no descendant break retain results of map/select/reject/partition; zero-argument bodies such as Hash#to_h and tally are also analyzed. The same-tree switch comparison on a84d3b60 reduces cached sends 2,858 to 2,853 (all by-name lines 2,919 to 2,914), adds one ivar pool and six exact index arms, and preserves nil error paths through six more nil-helper sites. Master's newly emitted block bodies explain the different starting count from ADR 0337. Overrides, forwarded blocks, captured writes and nonlocal callee exits retain refusal. See core Ruby returns.

Nested core results and Range inputs (ADR 0343)

On the same 959773b8 tree with the workspace's native submodule contents, BC2CPP_CORE_RUBY_NESTED_RESULTS=0 provides the control for the nested helper/super analysis and audited native range contract.

Measure Control Enabled
Cached calls, including helper-held sends 2,776 2,774
Ordinary send lines in bodies 2,349 2,350
Block funcall lines in bodies 403 400
Index helper callers 2,248 2,246
Numeric helper callers 3,264 3,264
Equality helper callers 110 110
Other body funcalls 30 30
Sites that can reach by-name dispatch 8,404 8,400

The four removals are three sort_by block fallbacks in Game::Transition#compute_block_order and one size fallback in Game::Transition#block_count_through. Two index helper callers become inline by-name fallbacks, so their disappearance is a relocation. All changes occur in RPG2k; core and other compiled gems retain their dynamic sites. See ADR 0343.

Profiler block results (ADR 0344)

On e50a09dc plus the Profiler helper-frame repairs, with the same workspace native sources, BC2CPP_PROFILER_RESULTS=0 supplies the control.

Measure Control Enabled
Cached calls, including helper-held sends 2,774 2,772
Ordinary send lines in bodies 2,350 2,348
Block funcall lines in bodies 400 400
Index helper callers 2,246 2,246
Numeric helper callers 3,264 3,264
Equality helper callers 110 110
Other body funcalls 30 30
Sites that can reach by-name dispatch 8,400 8,398

The two removed fallbacks are map width and height in RPG2k#start_new_game. The receiver is the Game::Map returned by the map-loading block; the remaining error branches are reviewed dead fallbacks. Game::State from the party block also establishes two argument pools and simplifies existing guard chains. No calls move into shared helpers. The nineteen @map receiver sites still require proofs for their other writers; the earlier seventeen-site counterfactual is not a measured saving from this change.

See ADR 0344. The result proof is now the checked declaration of ADR 0475 (Block return declarations), which removed the BC2CPP_PROFILER_RESULTS switch.

The Fixnum proof on a native arm's Integer argument (ADR 0358)

Fresh census on this tree (all three engine gems, RPG2k*/Game* owners in parentheses), shipped pass, 3rd/* populated. The nine removals are the BC2CPP_NATIVE_INT_GUARDS=0 control against the default, one tree, same method:

Measure Control Enabled
bc2cpp_send call sites, all gems 2,343 2,334
of them in RPG2k_*/Game_* 1,553 1,544
NATIVE_DIRECT_EXACT arms keeping an mrb_integer_p test 26 17
bc2cpp_getidx / setidx / slow_* / eqq callers unchanged unchanged

No helper gained a caller, so all nine are removals: z= 6, x= 2, flash 1.

Where the remaining 1,544 engine sites sit, by exclusive category:

Category Sites
core_tag_chain_else:receiver_other 589
rgss_native_exact_class_else 187
core_tag_chain_else:receiver_is_ivar 184
closed_world_kept:core_or_native 165
other:numeric_tag_guard 99
closed_world_kept:singleton_definer 81
other:no_guard_nearby 66
poly_diag:dynamic_single_registered_definition/receiver_class_unresolved 61
known_class_arm_still_by_name 34
everything else 78

core_tag_chain_else:receiver_other is the largest lever left and is a receiver-class problem, not an argument one: empty? 160, to_s 139, size 70, length 49. Its receiver-origin heuristic splits register_copy 291, direct_call_result 110, indexed_result 75, unknown 35 — and the first of those is a text heuristic that a register MOVE hides, so the real split is not yet measured.

Two categories are known over-conservative rather than genuinely dynamic: closed_world_kept:singleton_definer (81, all width/height/row, from the class << Graphics body in mruby-rgss/mrblib/lib.rb:1558) and closed_world_kept:core_or_native (165, where @outside_names is class-blind and unrelated mruby gems registering a name refuse the engine's own class). Neither is measurable without building its proof.

The core_tag_chain_else receiver-origin split is a floor, not an answer, and its blind spot is worth naming before the next round picks a target: register_copy fires on any register MOVE, and the emitter emits a MOVE for every Ruby local assignment, block-parameter binding and block-result assignment. So those 291 sites are an aggregate over several distinct Ruby-level origins, and reading them as one root cause would misdirect the work. Making receiver_origin follow MOVE chains is a measurement-only change to tools/bc2cpp/site_census.rb and would re-partition them before anything is built.

That re-split is now measured (census tool only; no generated code changes). receiver_origin follows rN = rM; and rN = self; back to the value's source and reads self, a method parameter, a literal (mrb_nil_value(), mrb_bool_value(...) and the like) or the receiver named directly. On the two saved shipped.cxx snapshots kept outside the tree (/tmp/keep_base, 2,306 sites, and /tmp/m_master, 2,208 sites), core_tag_chain_else:receiver_other goes from 834 to 826 and from 820 to 812, and register_copy (387 and 378) disappears. Before and after on /tmp/keep_base:

Origin Before After
register_copy 387 0
self (receiver named directly) 0 77
parameter 0 102
literal_or_fresh 23 105
unknown 123 35
direct_call_result 113 200
indexed_result 77 134
dynamic_call_result 34 73
other 40 63
captured_upvar 28 28
constant 9 9

The receiver_is_ivar category moves with it, 215 to 223 on that snapshot, because a copy of an ivar now reads as ivar_read. The 589 figure in the table above is from a wio build that is not saved here, so it cannot be re-split from these snapshots. The new direct_call_result and indexed_result mass is the copy-of-a-call chains that were hidden behind register_copy.

Three shapes are worth naming because they are gaps rather than missing proofs:

  • A local receiver that a fresh allocation or an inlined select/map collector produced. Game::State#to_lsd alone holds 13 empty? sites of this kind; block_result_array? already knows those calls return an Array, but exact_core_value_class never asks.
  • A method result whose receiver-specific return class exists but is refused. Game::Variables#to_h returns @data, a Hash the class layout already knows; codegen_return_classes.rb's RETURN_CORE_CLASS veto rejects the mask even though the scoped form is stricter than the name-wide one it falls back to.
  • A Hash#[] element. @ui[:skills] in the battle scene is a RecordHash whose per-key value classes RecordHash.analyze already computes, but which is not an oracle for element_index_mask.

See native arm Integer guards and ADR 0358.

Follow-up: closed-world helper else (ADR 0360)

Measured on the wio closed world (shipped pass) at master 0a9adfc, helper emission only:

Measure Before After Delta
bc2cpp_send calls held in helpers 24 23 -1
generated callers of a helper that holds a by-name call 5,781 5,509 -272
bc2cpp_send call sites in generated bodies 2,408 2,408 0
Sites that can reach by-name dispatch (bodies + helper callers + 417 block + 30 funcall sites) 8,636 8,364 -272 (-3.1%)

bc2cpp_slow_div (272 callers) is the only helper whose operator is answered by Integer and Float alone (CallFacts::Answers#members), so its else is now a proven NoMethodError. The ADR lists the answering classes of every other helper name: >>, round and ^ need operand-coercion or object.c bodies, + - * need Array/String (and, in this scan, Time) bodies that are static in mruby, < <= > >= need the Comparable includers, []/[]= have thirteen native classes besides the eight registry ones.

Follow-up: checked exact proofs in core bodies (ADR 0359)

The receiver_other core tag chain was not losing proofs through register copies: the flow and the dominating walk already follow MOVEs. Its unproven receivers have an unknown source (ivar, argument, call result, element, x || []). The one structural gap was that a compiled core body is compiled without the engine's world, so its literal and *rest receivers were never proven. They are now proven under the program world and checked: each site keeps one class test and its else is bc2cpp_guard_violation (GUARD_VIOLATION CORE_BODY_EXACT), not an unguarded access. BC2CPP_CORE_BODY_EXACT=0 disables it (the output is then byte-identical to master).

Measured on the wio closed world (shipped pass), same tree, switch off against on:

Measure Off On Delta
bc2cpp_send call sites in generated bodies 2,321 2,286 -35
mrb_funcall_with_block sites in generated bodies 417 414 -3
bc2cpp_guard_violation call sites (all families) 310 348 +38
CORE_BODY_EXACT_CHECKED sites (35 CLOSED_WORLD_NATIVE_EXACT, 3 BLOCK_CORE_DIRECT) 0 38 +38

The unguarded version of this change removed the same sites, so the check costs the class test and 38 cold violation arms, not the gain. See ADR 0359.

Follow-up: closed + and * helpers (ADR 0361)

Measured on the wio closed world (shipped pass) at master 9361f234. The census scan now skips the by-name copy that a closed helper keeps under #if defined(MRB_USE_COMPLEX) || defined(MRB_USE_RATIONAL) (wio never defines them), so the / helper of ADR 0360 no longer counts either:

Measure Before After Delta
bc2cpp_send calls held in helpers 23 21 -2
generated callers of a helper that holds a by-name call 5,509 4,355 -1,154
bc2cpp_send call sites in generated bodies 2,321 2,321 0
Sites that can reach by-name dispatch (bodies + helper callers + 417 block + 30 funcall sites) 8,277 7,123 -1,154 (-13.9%)

bc2cpp_slow_add_f (712 callers) and bc2cpp_slow_mul_f (442) switch on Integer, Float, String and Array tags and raise the proven NoMethodError for any other receiver. bc2cpp_slow_sub_f (471) stays: Array#- (mruby-array-ext) is a hash walk with no entry point to mirror, and Time answers - wherever mruby-time is linked.

Follow-up: closed comparison helpers (ADR 0362)

bc2cpp_slow_lt/le/gt/ge (296 + 134 + 311 + 166 generated callers) now carry a closed form beside the by-name helper (the latter under #if defined(MRB_USE_COMPLEX) || defined(MRB_USE_RATIONAL)). Wio closed world, shipped pass, master 9361f234 against the branch, with the census counting only the live arm of a helper written twice (see the combined section below for the census fix that makes that hold for nested #if):

Measure Before After
bc2cpp_send calls held in helpers 23 23
generated callers of a helper that holds a by-name call 5,509 5,509
bc2cpp_send call sites in generated bodies 2,321 2,321

The headline number does not move: the closed form of each helper still holds one by-name call, for a Hash receiver, because Hash#< is Ruby over == of the stored values (mruby-hash-ext). What changed is which receivers can reach it: before, every non-number (String, Symbol, nil, any object) dispatched by name; now only a Hash does, and every other receiver is a proven NoMethodError or Comparable's body computed in C++. The closed form's Hash call sits beside the unchanged #if copy, which the census does not count. Closing the last arm needs a per-element == proof or a compiled Hash#< reachable from the helper's translation unit.

Follow-up: core-body helper closure (ADR 0364)

Measured on the wio closed world (shipped pass) at master 9361f234; the #if arm kept for Complex/Rational builds is stripped from both runs, as ADR 0360 counted /.

Measure Before After Delta
bc2cpp_send calls held in helpers 23 19 -4
generated callers of a helper that holds a by-name call 5,509 5,483 -26
bc2cpp_send call sites in generated bodies 2,321 2,321 0

bc2cpp_slow_xor (4 callers), bc2cpp_slow_rshift (17) and bc2cpp_slow_round (5) mirror the bodies of Integer, nil/true/false and Float inside the helper. & | << % -@ zero? === stay open: their member sets contain a static body that public API cannot reproduce (Array, String, IO), Ruby definers, or are unbounded (ADR 0364 lists each).

Combined: ADR 0360, 0361, 0362 and 0364 together

The helper keys are independent (add/mul 0361, lt/le/gt/ge 0362, xor/rshift/round 0364, div 0360), so one shipped pass shows every closure at once. Wio closed world, shipped pass, master 9361f234 against the combined tree, both measured with the same census. The census now also keeps a helper's own #ifdef and #else inside the by-name copy from ending its mask early: bc2cpp_slow_rshift's by-name copy has #ifdef MRB_USE_BIGINT ... #else return bc2cpp_send(...), and that inner #else used to leave one >> call live and attributed to the round helper.

Measure Before After Delta
bc2cpp_send calls held in helpers 23 17 -6
generated callers of a helper that holds a by-name call 5,509 4,329 -1,180
bc2cpp_send call sites in generated bodies 2,321 2,321 0
Sites that can reach by-name dispatch (bodies + helper callers + 417 block + 30 funcall sites) 8,277 7,097 -1,180 (-14.3%)

The five helpers that left the list (six by-name calls, rshift held two) are bc2cpp_slow_add_f (712 callers), bc2cpp_slow_mul_f (442), bc2cpp_slow_rshift (17), bc2cpp_slow_xor (4) and bc2cpp_slow_round (5). The comparison helpers stay listed with their one Hash call.

Follow-up: what remains after ADRs 0360-0368, and module singleton arms (ADR 0369)

Measured at master c973a581 (PRs #2044-#2046 merged), wio closed world, shipped pass, the same tree before and after (3rd/mruby, mruby-marshal, mruby-onig-regexp and mruby-stringio populated; a run without the last one lacks the StringIO bodies and reads 2,236 sites).

Measure Before After Delta
bc2cpp_send call sites in generated bodies 2,286 2,205 -81
bc2cpp_send calls held in helpers 17 17 0
bc2cpp_nomethod sites 4,460 4,541 +81
closed_world_kept: singleton_definer 91 10 -81
callers of a helper holding a by-name call unchanged unchanged 0

All 81 are removals (width 47, height 33, transition 1): the one singleton definer of those names is RGSS::Graphics, a module, and the chains already listed every instance definer. The else is a bc2cpp_nomethod behind an identity arm (mrb_type == MRB_TT_MODULE && mrb_class_ptr == RGSS::Graphics), 63 new NOMETHOD_REVIEWED keys. The 10 left (action 3, wait 3, row 3, term 1) are singleton definers on class objects.

Remaining sites, by name and by reason (2,286 before the change)

By name: [] 208, empty? 202, size 176, to_s 151, length 69, new 66, to_enum 66, to_i 60, []= 51, push 50, width 47 (now 0), x= 44, y= 44, update 42, include? 37, name 37. 241 names remain; the top five ([], empty?, size, to_s, length) are 806 sites. Of the 2,286, 80 sit in the RGSS probes (effect_probe, audio_probe, probe_wav, ...) that scripts/wio/strip_rgss_probes.rb deletes from the real wio build: the census counts them, the shipped binary does not.

By reason, with the proof each one needs (the receiver class sets come from the exact-class flow):

Reason Sites Why the receiver is unproven The proof that would close it
core tag chain else, receiver not an ivar 814 register copy 658, direct-call result 206 (project getters and LCF readers whose result class set is a union with an unpooled return), GETIDX element 61+ (elements of containers filled by map/masgn/parsed data) element and tuple-slot classes of the container (ADR 0312 measured it as the dominant unknown); return-class sets for getters over @x || [] stores
core tag chain else, receiver is an ivar 215 @list (22) is stored from a parameter (commands || []) and from @list, @index, @id = @call_stack.pop, a tuple slot of another ivar's element; @actors from map { @roster[i] }.compact argument pools for start(commands) plus tuple-slot typing of @call_stack elements; map over an unproven receiver is not an Array while a user-defined map exists
RGSS native exact-class else 206 the receiver is proven (Sprite, Window); an argument (x=, y=) is not provably an Integer argument-shape propagation across call edges (ADR 0358 does the call-local part)
core_or_native 186 name 37, delete 18, map 17, resume 11: a native or core definer the world cannot exclude, receiver unproven an exact receiver class per name
receiver_class_unresolved (POLY_DIAG) 210 inspect 28, call 14, <=> 12, procs and user objects inherent
implicit_self_unresolved 194 to_enum 66: Kernel#to_enum lives in mruby-enumerator, whose bodies stay interpreted (Fiber and closures), so there is no direct target whatever the receiver not closable by analysis; needs a compiled enumerator body
numeric tag guard 121 [] behind an Integer test, the receiver is an exact Array/Hash and the index is not provably an Integer the index-body helper work (not analysis)
no_guard_nearby 95 to_f 20 (a Float-only arm, the Integer registration is not a derived expression), === 11 in core bodies (Array#deconstruct), abs 17 an Integer to_f arm only moves the line, a receiver proof removes it
singleton_definer 91 to 10 a module singleton definer is not an instance of any listed class ADR 0369
dynamic_install 29 update: a runtime definition site can install the name per-class resolution of the installer

Judged not worth doing (or unsound) as analysis changes: widening direct_callable? to &block bodies (ADR 0341: one site); arming class-object singleton definers the same way (10 sites left, and a class object has class-side inheritance, so each subclass needs its own arm); dropping the probe sites by analysis (they are stripped by the build, the census should read the stripped source instead).

Follow-up: exported core bodies (ADR 0367)

bc2cpp_slow_mod (138 generated callers, two by-name calls) and bc2cpp_slow_neg_f (71 callers, one) carry a closed form that calls bodies the pinned fork commit (mruby-expose-misc-bodies) exports (Integer#%, Float#%, String#-@, the sprintf formatter). Wio closed world, shipped pass, branch base b4efe59e (PR #2045's head) against the branch, the #if arm for Complex/Rational builds stripped from both runs as before:

Measure Before After Delta
bc2cpp_send calls held in helpers 17 14 -3
generated callers of a helper that holds a by-name call 4,331 4,122 -209
bc2cpp_send call sites in generated bodies 2,306 2,306 0
Sites that can reach by-name dispatch (bodies + helper callers + 414 block + 30 funcall sites) 7,081 6,872 -209 (-3.0%)

The generated method bodies are byte-identical (only the two helpers changed). What stays open, and why:

Helper Callers Held by-name call Why it cannot close exactly
bc2cpp_slow_zero 33 zero? Numeric#zero? is self == 0: any Numeric that is not an Integer or Float dispatches == by name
bc2cpp_slow_lt le gt ge 908 Hash#< ... Ruby over all?, key? and == of the stored values; mrb_equal differs for NaN and a user ==
bc2cpp_eqq 110 === Kernel#=== answers every object; Data (Regexp) and Proc === are static in other gems

Index helpers closed (ADR 0365)

bc2cpp_getidx, bc2cpp_getidx0 and bc2cpp_setidx no longer hold a by-name call: 2,017 + 32 + 348 generated callers stop reaching by-name dispatch, helper-held by-name calls go from 19 to 16 and bc2cpp_send in the helper region from 17 to 14 (shipped wio pass, base b4efe59e). Bodies and nomethod sites are unchanged (2,286 and 4,460).

Follow-up: exported collection bodies (ADR 0366)

the pinned fork commit (mruby-expose-collection-op-bodies) exports Array#-, #&, #| (mruby-array-ext), String#<< (mruby-string-ext) and IO#<< (mruby-io), and the - & | helpers now call them instead of dispatching by name. Wio closed world, shipped pass; "before" is master da29f561 (ADR 0365 and 0367 merged) over an mruby tree with the misc and index patches only, "after" the branch over all three. The generated method bodies are identical (0 methods differ once symbol and send indices are normalised).

Measure Before After Delta
bc2cpp_send calls held in helpers 11 8 -3
helpers that hold a by-name call 10 7 -3
generated callers of a helper that holds a by-name call 1,723 1,172 -551
bc2cpp_send call sites in generated bodies 2,205 2,205 0
Sites that can reach by-name dispatch (bodies + helper callers + 414 block + 30 funcall sites) 4,372 3,821 -551 (-12.6%)

The three removals are bc2cpp_slow_sub_f (471 callers), bc2cpp_slow_and (66) and bc2cpp_slow_or (14). bc2cpp_slow_lshift (122) stays: the wio world has two more << definers, RGSS::ErrorReport::Tee#<< (compiled Ruby) and Enumerator::Yielder#<< (interpreted Ruby of mruby-enumerator), and the second has no compiled body to call. The closed << form is generated, and proven against the real operators, in worlds without such a definer. Builds that link mruby-time (psp, maix, desktop) keep - open because Time#- is static.

Follow-up: the Hash arm of the comparison helpers through compiled core (ADR 0371)

bc2cpp_slow_lt/le/gt/ge (296 + 134 + 311 + 166 generated callers) no longer hold a by-name call in a closed world that compiles mruby's own Ruby: the Hash branch calls the compiled Hash#< family behind an exact-Hash test. Wio closed world, shipped pass, same tree with BC2CPP_CORE_COMPILED_CMP=0 as the base (the #if arm for Complex/Rational builds stripped from both runs):

Measure Before After Delta
bc2cpp_send calls held in helpers 17 13 -4
generated callers of a helper that holds a by-name call 4,329 3,422 -907
bc2cpp_send call sites in generated bodies 2,205 2,205 0
Sites that can reach by-name dispatch (bodies + helper callers + 414 block + 30 funcall sites) 6,978 6,071 -907 (-13.0%)

The three shipped closed builds (wio, psp, maix) are hot-only and hold no core Ruby, so the arm exists only in the census world and in a closed build with BC2CPP_HOT_ONLY=0. Core compile counts are unchanged (215 core-source methods, 190 compiled, 25 interpreted; the compiler now prints the 25 with their reasons). Still open: zero? (33, the receiver set is unbounded because File.zero?/FileTest.zero? are native class methods), === (110), & | << - and the index helpers when the index-body patch is absent.

Follow-up: native parameters unboxed (ADR 0372)

A native direct arm no longer tests an :int argument for Integer: when the closed world proves the name reaches the RGSS native, the call site runs mrb_as_int (mrb_get_args "i" is exactly that, in 3rd/mruby/src/class.c) on each argument as its own statement, in argument order, then calls the entry point. The old arm's else was a by-name send whose only job was to re-derive what the conversion does.

Wio closed world, shipped pass, same tree, BC2CPP_NATIVE_PARAM_UNBOX=0 against default (scripts/bc2cpp/coverage_report.rb with BC2CPP_COVERAGE_KEEP_DIR, then scripts/bc2cpp/dynamic_site_census.rb):

Measure Control Enabled
bc2cpp_send call sites, generated bodies 2,208 2,101
bc2cpp_nomethod sites (not dynamic) 4,541 4,560
rgss_native_exact_class_else sites 206 118
generated shipped.cxx 21,461,897 B / 441,808 lines 21,523,288 B / 443,666 lines

rgss_native_exact_class_else by name (control / enabled): x= 36 / 0, y= 36 / 0, z= 7 / 0, flash 18 / 6, new 62 / 39, opacity= 3 / 3, _transition_alpha 1 / 1, update 10 / 10, fill_rect 7 / 17, draw_text 7 / 19, clear 7 / 10, blt 7 / 7, text_size 4 / 4. The three growing rows hold no :int argument (their entries take :value): they are the same sites, reclassified once the x=/y= ones in front of them stopped matching. The 19 new bc2cpp_nomethod sites are chains whose last else is now proven dead (eight new reviewed keys, all Viewport/Bitmap ivars set only from Viewport.new/Bitmap.new). The file grows 0.3% by the per-arm blocks.

What stays gated: Bitmap.new's first argument (Bitmap#initialize branches on f.kind_of? String), any name an alias, Symbol definition, computed-name installer, undef or visibility change can reach, and every arm of an open world. See ADR 0372 and scripts/bc2cpp/native_param_unbox_check.rb.

Follow-up: the zero? helper through compiled core (ADR 0374)

bc2cpp_slow_zero (33 generated callers) no longer holds a by-name call in a closed world that compiles mruby's own Ruby: every Numeric that is not a Float runs the compiled Numeric#zero? (its == is the body's own site), File, FileTest and File subclasses raise the File.zero? argument error (the helper has no argument), and every other receiver is a proven NoMethodError. Wio closed world, shipped pass, base 38f7267a (master after ADR 0371), the #if arm for Complex/Rational builds not counted:

Measure Before After Delta
bc2cpp_send calls held in helpers 13 12 -1
generated callers of a helper that holds a by-name call 3,423 3,390 -33
bc2cpp_send call sites in generated bodies 2,184 2,183 -1
bc2cpp_nomethod sites 4,539 4,540 +1
Sites that can reach by-name dispatch (bodies + helper callers + 414 block + 30 funcall sites) 6,051 6,017 -34 (-0.6%)

The body delta is the scan change that came with it: a name that native sources spell only as class-level registrations (start is GC.start) is attributed to its class object instead of left unbounded. The shipped firmware closed builds are hot-only and hold no core Ruby, so none of this applies there; it is the census world and BC2CPP_HOT_ONLY=0 builds. Still open: === (110), & | << - and the index helpers when their body patches are absent; implicit_self_unresolved is 198 body sites (88 + 82 + 27 + 1 by registered-definition path).

Follow-up: checked setter-site pools (ADR 0370)

BC2CPP_SETTER_POOLS=0 against the default, wio closed world, shipped pass; the off run is cmp-identical to the tree without the change on both trees. PR #2050's identity arms for module singleton definers (ADR 0369) removed the singleton_definer elses the pools' width/height arms also shed, so the gain is measured before and after it:

Measure Branch point 8ab8aa75 off on Delta master 16e2bc43 off on Delta
bc2cpp_send call sites in generated bodies 2,286 2,259 -27 2,208 2,197 -11
mrb_funcall_with_block 414 414 0 414 414 0
bc2cpp_nomethod sites 4,460 4,458 -2 4,541 4,539 -2
CHECKED_POOL_EXACT arms (class test, nil arm, guard-violation else) 0 25 +25 0 9 +9

Body bc2cpp_send sites by name, the ten largest and every name that changed, branch point (the ten largest do not move on either tree):

Name Off On Delta
[] 208 208 0
empty? 202 202 0
size 176 176 0
to_s 151 151 0
length 69 69 0
new 66 66 0
to_enum 66 66 0
to_i 60 60 0
[]= 51 51 0
push 50 50 0
width 47 34 -13
x= 44 42 -2
y= 44 42 -2
height 33 30 -3
name 37 35 -2
clear 7 5 -2
blt fill_rect text_size 7 + 7 + 4 6 + 6 + 3 -1 each

On master width and height have no by-name site left, off or on, and every other changed row is identical (-11 in all). By category on master: rgss_native_exact_class_else 206 to 197, closed_world_kept:core_or_native 188 to 186; the two core_tag_chain_else rows (820 and 216) do not move. Element, tuple-slot and argument-Integer typing are not built (ADR 0370, Context).

Follow-up: splatted blk.call(*args) on a core block (ADR 0373)

Wio closed-world shipped pass, master 38f7267a, before and after. All 28 by-name "call" sites in generated bodies (mrb_funcall_argv(M, r, "call", RARRAY_LEN, RARRAY_PTR), the else arm of CORE_PROC_CALL's runtime-sized splat) were in Enumerable block bodies of mruby's enum.rb: 26 block.call(*val) on the method's own &block (22 methods) and 2 yield(*val) in Enumerable#cycle (a BLKPUSH). Each is now a mrb_proc_p arm that yields with the Array's length and elements, and a bc2cpp_nomethod else.

Measure Before After Delta
by-name "call" sites in bodies 28 0 -28
mrb_funcall / _argv / _id in bodies 30 2 -28
bc2cpp_nomethod sites 4,539 4,567 +28
bc2cpp_send sites in bodies 2,197 2,197 0
Receiver Sites Result
method's own &block through GETUPVAR (BLOCK_PARAM_CALL proof) 26 closed
BLKPUSH (yield *val) 2 closed
stored callable, Method, plain parameter 0 none with a runtime-sized splat

The two remaining body mrb_funcall* sites are not call. Core bodies exist only in this measurement world and in a BC2CPP_HOT_ONLY=0 closed build, so the shipped firmware is byte-identical.

Follow-up: block loops inside a rescue range, and each over Array rows (ADR 0376)

Wio closed-world shipped pass, master d7212a1c. "Before" is the same tree with BC2CPP_RESCUE_INLINE_BLOCKS=0 BC2CPP_EACH_SPREAD=0 (byte-identical to master), "after" has the change. Two worlds: the full one (every method of the three compiled gems) and the hot-only one the firmware ships (BC2CPP_HOT_METHODS=tools/bc2cpp/hot_methods.txt, ADR 0214).

# once per world and per switch setting (KEEP_DIR keeps shipped.cxx)
MRBC=<host mrbc> [BC2CPP_HOT_METHODS=$PWD/tools/bc2cpp/hot_methods.txt] [BC2CPP_RESCUE_INLINE_BLOCKS=0 BC2CPP_EACH_SPREAD=0] \
  BC2CPP_COVERAGE_KEEP_DIR=/tmp/keep-<name> ruby scripts/bc2cpp/coverage_report.rb > /dev/null
ruby scripts/bc2cpp/rescue_inline_census.rb /tmp/keep-before/shipped.cxx /tmp/keep-after/shipped.cxx \
  apply_move_requests apply_location_requests apply_halt_request apply_sprite_flash_requests build_parallels \
  draw_transition_mask patch_anim_cells

A method's functions are its entry, _impl, try bodies, block functions and inlined-section functions. "Markers" are // BLOCK_FALLBACK lines: one RProc and one block function each.

Measure Hot-only before Hot-only after Full before Full after
mrb_funcall_with_block sites 39 29 445 441
BLOCK_FALLBACK markers 37 27 461 439
generated functions 808 788 6,771 6,727
lines inside generated functions 55,743 55,569 444,731 444,272
lines of shipped.cxx 56,014 55,840 445,055 444,596

The seven methods of the ADR (apply_move_requests, apply_location_requests, apply_halt_request, apply_sprite_flash_requests, build_parallels, draw_transition_mask, patch_anim_cells, all Scene::Map):

Measure Hot-only before Hot-only after Full before Full after
mrb_funcall_with_block sites 10 1 1 1
BLOCK_FALLBACK markers 10 1 10 1
generated functions 40 22 40 22
lines of those functions 1,824 1,672 1,922 1,778

The full world has one mrb_funcall_with_block there before as well as after because ADR 0310's direct arms already call the compiled Array#each with the block's RProc; the markers show what is removed (the RProc and its block function). Per method (hot-only, funcall sites / lines): apply_move_requests 1 to 0 / 143 to 130, apply_location_requests 1 to 0 / 144 to 130, apply_halt_request 1 to 0 / 145 to 132, apply_sprite_flash_requests 1 to 0 / 207 to 191, build_parallels 4 to 1 / 692 to 643, draw_transition_mask 1 to 0 / 310 to 289, patch_anim_cells 1 to 0 / 183 to 157. The remaining build_parallels site is @common.each, whose receiver is not a proven Array.

Outside the seven methods the hot-only world loses one more site (Game::Interpreter#key_input_result, an unprotected arity-2 each over a constant Array of pairs), the full world loses it too and twelve more markers (Game::Actor#learn_level_skills, Game::State#seed_screen_transitions and #seed_vehicle_positions, Scene::Battle#apply_battle_event_requests (2), #battle_recovery_lines, #draw_battle_stat_segment, #run_battle_events, Scene::Map#draw_captured_transition, RGSS.windowskin_rect_probe, RPG2k#continue_game, RPG2k#start_new_game). bc2cpp_send, bc2cpp_nomethod (NOMETHOD_REVIEWED) and POLY listings are identical before and after, and there are no #error markers in either file.

Follow-up: class narrowing by a runtime test (ADR 0375)

Wio closed-world shipped pass (scripts/bc2cpp/coverage_report.rb, 3rd/* populated, host mrbc prebuilt, LANG=C.UTF-8), master 233972ac against the same tree with the change. BC2CPP_CLASS_NARROWING=0 against the tree without the change is cmp-identical (the shipped C++ of both runs, 21.5 MB). The engine rarely tests a class and then calls a method that was still unproven, so the gain is small; what it removes is by form below.

Measure Off On Delta
bc2cpp_send call sites in generated bodies 2,063 2,059 -4
mrb_funcall_with_block 414 413 -1
by-name sites in total (the two above plus 2 mrb_funcall) 2,479 2,474 -5
reach sites (by-name, slow_*, getidx*, setidx, eqq callers) 8,260 8,247 -13
bc2cpp_nomethod sites 4,570 4,535 -35
NOMETHOD_REVIEWED sites / keys (the full world) 4,293 / 3,015 4,260 / 2,985 -33 / -30
POLY_SMALL_N chains 2,431 2,398 -33
IVAR_ACCESSOR / USER_RECEIVER_CASES direct arms 1,136 / 1 1,161 / 7 +25 / +6
NILABLE_RECEIVER / EXACT_TYPED 934 / 20 931 / 21 -3 / +1
getidx / getidx0 callers 2,017 / 32 2,013 / 31 -4 / -1
slow_ge / slow_lt callers 166 / 296 164 / 295 -2 / -1
bc2cpp_guard_violation sites 355 572 +217 (216 are CLASS_NARROWING run-time guards)

Tests the engine spells, by form, and what each closed (functions the narrowing changed: 34 of 3,047):

Form Narrowing sites (guards) Closed
respond_to?(:m) 180 31 bc2cpp_nomethod and 29 POLY_SMALL_N chains of the m call (the chain over every definer becomes an exhaustive class case or an ivar accessor), 1 bc2cpp_send
is_a? / kind_of? 35 3 bc2cpp_nomethod, 4 POLY_SMALL_N, 4 bc2cpp_send, 1 mrb_funcall_with_block; the Integer, Array and Hash tests also make 4 getidx, 1 getidx0, 2 slow_ge and 1 slow_lt arms exact
nil? as a value, !x no run-time guard (the test computes its own answer) 0: removing nil from an unproven receiver proves nothing, and x.nil? in a condition was already a JMPNIL
=== / case x when C, instance_of?, x.class == C 0 the engine's case arms name Integer constants and literals, and no site spells the other two
raise ... unless, &&/` ` joins

What stays by name in the sites that carry a test: a test of a class with no bit (Symbol, true/false, a native class) narrows nothing from an unknown value; a receiver that is a call result or an element of a container has no variable to narrow; an else edge only drops the classes that pass, so it narrows a known set and never an unknown one.

Follow-up: exact receiver origins (SiteOriginTable)

The receiver origin is now the single reaching definition of the receiver register, from the same BytecodeIR.reaching_definitions the code generator uses (MOVE chains followed, through joins and loops). tools/bc2cpp/site_origin_table.rb (BC2CPP_SITE_ORIGIN_TABLE, set for the shipped pass by BC2CPP_COVERAGE_ORIGIN_TABLE) tags each by-name line with its (irep, instruction) and writes one row per send: the status, the origin category and the writer as OP@index. The census (--origins) reads the tag and the table; no text is walked. Categories are unchanged. How a writer is classified:

  • ENTRY: self for register 0, parameter for a positional register, literal_or_fresh otherwise (the nil every local starts as); LOADSELF is self.
  • GETCONST/GETMCNST are constant; GETIV is ivar_read or embedded_ivar by its emitted form.
  • Any other writer: the right-hand side compile_insn emitted for it, through the same rules as the text walk. Several producers inside one instruction (a fast path and its dispatch) give that origin only when they agree; otherwise the origin is unknown.
  • ambiguous (several definitions reach through a join or a loop), refused (the dataflow cannot prove the set: handler and protected ranges, unmodelled ops) and untagged (see "Dispatch-line rows" below for what is left) are unknown. reg_mismatch is a row whose register is not the line's: it cannot arise now, since the tag carries the line's own register.

Measured on the wio closed world, shipped pass, master 2b1867b0 (the branch base; the master pass is this same tree, so one build covers both). Before is the text walk over the untagged shipped.cxx; after is the exact walk over the tagged build. The tagged file with the /*SO:*/ comments removed is byte-identical to the untagged one.

The table below is table-wide, not by-name sends. Its 7,428 rows are every row of the origin table (one per distinct by-name tag the code generator recorded, helper rows included). That is more than the census sites (2,082 at f8ff1015) and more than the tags the shipped file carries (1,770 before the per-line tags, 2,022 after), so it is not a count of by-name sends. The by-name census sites are in the table Origin of the receiver (census sites) further down.

Origin (table rows) Text walk (before) Exact walk (after) Delta
literal_or_fresh 1,726 409 -1,317
unknown 1,251 2,701 +1,450
direct_call_result 1,207 866 -341
dynamic_call_result 882 58 -824
other 610 585 -25
parameter 499 827 +328
indexed_result 457 504 +47
ivar_read 363 362 -1
embedded_ivar 247 301 +54
constant 80 654 +574
self 68 68 0
captured_upvar 38 93 +55
total rows 7,428 7,428 0

Table rows whose origin changed: 5,323 of 7,428 (72%). By cause: 3,124 exact walks that now name a different category; 848 rows whose defining instruction has two producers that disagree (a fast path and its dispatch), which the multi-producer rule answers unknown (not a register mismatch; reg_mismatch is 0, see the dispatch-line rows below); 489 refused, 451 ambiguous and 404 untagged rows that were guessed before and are now unknown; 7 not_a_register.

Ambiguous rows: 547 (status ambiguous, counted as unknown). The table holds 777 ambiguous writes across all by-name and helper rows. Exact status over the 7,428 rows: 5,692 exact, 630 refused, 552 untagged, 7 not a register and 547 ambiguous. The 630 refused and 552 untagged are table-wide counts on 2b1867b0, before the transfers and per-line tags; they are not census sites. Raising the dataflow state cap (STATE_CAP) left every count unchanged, so the refusals are not a budget artefact.

The table-wide dynamic_call_result drop (882 to 58 rows) is mostly the text walk reading the dispatch line of a send whose value also comes from a fast path (428 of the 882 rows are now unknown), not a removed dispatch: the generated code is unchanged.

Refused origins: origin-only transfers

The origin walk (BytecodeIR.reaching_definitions with origin_transfers: true, used only by SiteOriginTable) follows three ops that the codegen walk refuses: JMPUW writes no register; RESCUE a b writes only R[b]; EXCEPT a writes R[a] on every entry, so its handler edges need not be known for that register. Each follows vm.c. Codegen never passes the option, so generated C++ is unchanged: the shipped pass with the /*SO:*/ comments stripped is byte-identical before and after (442,855 lines). scripts/bc2cpp/ir_facts_check.rb has a positive and a negative case per transfer.

The origin walk also refuses where vm.c writes above an op's leading register, which the codegen walk treats as a write of the leading register only: the fallback send of GETIDX0, GETIDX, SETIDX, the operator ops and the comparisons (a frame at R(a), nil at R(a+2), and R(a+1) or R(a+3) for some of them), BLKCALL (clears every register above its arguments), and the rest, post, keyword and block slots of ENTER. Each refusal is a :callee_clobber or an unmodelled refusal in the origin walk only. The shipped pass with the /*SO:*/ comments stripped is byte-identical to master (origin/master c1372ea3). scripts/bc2cpp/origin_multiwrite_check.rb has the positive and negative cases, and pins the codegen walk's answers.

Refusal causes at the site level (the 232 refused bc2cpp_send sites on master f8ff1015, from the census's own scan joined to the probe log of each walk's first refusal):

Cause (first refusing op) Before After Status
query_guarded: the send itself lies in a begin/rescue/ensure range (SEND 62, SEND0 59) 121 121 unknown: exception flow is not modelled
callee_frame_clobber (SEND 34, SEND0 31, SSEND 11, SENDB 1) 77 77 unknown: a callee's frame overwrites registers above its base
op_no_transfer RESCUE 11 0 resolved (RESCUE writes R[b])
op_no_transfer JMPUW 6 0 resolved (writes no register)
pred_guarded EXCEPT (handler target) 5 12 partly resolved: the query register equal to R[a] is defined; other registers stay unknown
no_preds JMP (dead or handler-reached code) 9 11 unknown
opaque_reg MOVE (a nested block's SETUPVAR) 3 3 unknown
total 232 224

The EXCEPT row rises from 5 to 12 because RESCUE sites now walk on to their handler-target EXCEPT, and the transfer above resolves the ones that query R[a]. Site status after: exact 1,513 (was 1,505), refused 224 (was 232), ambiguous 98 (unchanged), untagged 241 and not_a_register 7 (unchanged). Table-wide refused rows: 1,665 to 1,495.

Left unknown, with reasons: a send inside a protected range (the walk does not model the handler's entry values; through_handlers would answer it with ambiguous sets, which is a different query); a callee frame's clobbered register (its value is the callee's, not the caller's); dead or handler-reached code with no normal predecessor; a register written by a nested block.

Dispatch-line rows

Each by-name line now carries its own tag, /*SO:<label>:<index>:<reg>:<walk>*/:

  • reg is the register the line receives (the receiver of its first by-name call), so a row cannot name a different register than the line dispatches on (reg_mismatch cannot arise).
  • walk is the register the reaching-definition walk runs on. It is reg except in a shifted block body (compile_block_body_insn, offset > 0): there the line names the shifted register, and the block's own register is reg - offset. The fast path's proof walks that register (unshift_proof_reg); the table walked the shifted number in the block's irep before.
  • A by-name line that compile_send returns untagged (a splat or keyword send, no site) is tagged by the compile_insn that emitted it, when the line receives a register that instruction names. The GETIDX and SETIDX fallbacks, emitted by the codegen directly, are tagged this way.
  • The table has a seventh column, walk. Rows are keyed by (label, index, reg, walk).

Measured on the wio closed world, shipped pass, master f8ff1015, before and after (the same tree, the same MRBC). The tagged file with the /*SO:*/ tags removed is byte-identical to the untagged shipped pass both before and after (21,505,993 bytes; 1,770 tags before, 2,022 after).

Status (census sites) Before After
exact (of which 332 self) 1,504 1,816
refused 232 156
ambiguous 98 103
untagged 241 0
not_a_register 7 7
reg_mismatch 0 0
no_table_row 0 0
sites 2,082 2,082

reg_mismatch is 0 on master as well. The 848 table rows quoted above are not a register mismatch: they are definitions whose single instruction has two producers (a fast path and its dispatch both write the same register), which the category rule answers as unknown and which this change leaves as it was. After the change that is 126 exact sites (unknown by that rule).

Origin of the receiver (census sites, unknown = every non-exact status plus the multi-producer rule):

Origin Before After
unknown 691 392
parameter 216 276
embedded_ivar 122 180
ivar_read 109 164
direct_call_result 195 235
constant 84 135
captured_upvar 48 91
indexed_result 144 149
literal_or_fresh 74 56
other 62 67
self 332 332
dynamic_call_result 5 5

By-name census sites only (not the table-wide rows above): on the f8ff1015 shipped pass, direct_call_result is 195 before and 235 after, and dynamic_call_result is 5 before and 5 after (2,082 sites in all).

What the 241 untagged sites became: 239 exact, 1 ambiguous, 1 refused. Each now resolves through the register its own line receives.

Sites whose origin or status changed: 333, by cause:

  • 214 unshifted: the untagged sites above.
  • 138 in a shifted block body (every such site): 27 were untagged; 77 moved refused to exact (73) or ambiguous (4), which the block's own register answers; 34 stayed exact with a different origin (literal_or_fresh to parameter, captured_upvar or constant), because the walk now runs on the block's register.

Classification: 83 sites moved from core_tag_chain_else:receiver_other to core_tag_chain_else:receiver_is_ivar, because that category reads the receiver origin and their receiver is now an ivar read. No other classification changed. The shape, marker, why and guard columns are unchanged for all 2,082 sites.

Remaining unknown after the change: refused 156 and ambiguous 103 (dataflow limits, not a dispatch register problem), not_a_register 7, and 126 exact sites whose one defining instruction has two producers.

Follow-up: refusal causes and exception flow in the origin walk (origin-only)

Every refused row of the origin table now names its first refusal cause in the category column (- is kept for every other status). The causes are the refusals of BytecodeIR.reaching_definitions:

  • query_guarded: the walk reached a protected instruction (a begin/rescue/ensure range, end included) or a handler target. The normal walk does not model exception flow, so it refuses there.
  • callee_frame_clobber: a callee frame (SEND, SEND0, SSEND, SENDB, SUPER, EXEC) at a lower register than the one asked about lies between the definition and the use. A callee's frame starts at R(a) and may overwrite every register above a, so the walk refuses (the rule is deliberate; see the last section below).
  • unmodelled:<OP> (an op outside the write model, e.g. RESCUE, EXCEPT, JMPUW), no_predecessor, opaque_reg, state_cap, unresolved, bad_index and move_without_source.

The re-ask is on by default. BC2CPP_SITE_ORIGIN_EXCEPTIONS=0 turns it off (read only by the table writer, so no generated code changes either way). It answers a refused query again through through_handlers: true (ADR 0285's walk: each instruction that can raise into a handler contributes the value it is entered with and the value it leaves). The answer is taken only when it is one definition; two or more give ambiguous, and a refusal keeps its cause. It is sound because the normal walk's answer is unchanged whenever it does not refuse (a walk that never touches a protected instruction or a handler target sees no handler edge), so the option only replaces refusals. The edges are the VM's: catch_cover_p in mruby's vm.c covers an instruction when begin < pc <= end with pc already past it, which is the half-open [begin, end) range the walk uses. Checked by scripts/bc2cpp/site_origin_exceptions_check.rb (a raise path that changes the receiver, a retry that re-enters the send, and a send whose receiver it writes itself stay unknown).

Measured on the wio closed world, shipped pass, master d9f0f10d (BC2CPP_COVERAGE_ORIGIN_TABLE; shipped.cxx with the tags is byte-identical with the option off and on, and both match the master pass). Census sites:

Refusal cause (census sites) Before (option off) After (option on)
query_guarded 132 0
unmodelled:RESCUE 11 11
unmodelled:EXCEPT 0 8
unmodelled:JMPUW 6 6
no_predecessor 9 9
opaque_reg 3 3
callee_frame_clobber 0 0
refused 161 37
ambiguous 103 112
exact (receiver origin) 1,871 1,986

The 124 sites answered are 115 exact and 9 ambiguous; the other 8 query_guarded sites reach an EXCEPT (a handler's own op, refused on the handler path). The receiver origins that change are the census sites whose origin is unknown (399 before, 286 after): constant +50, indexed_result +19, embedded_ivar +6, ivar_read +12, parameter +14, direct_call_result +5, other +6, literal_or_fresh +1.

Re-ask on by default (master cfd027ac)

Measured again on the wio closed world, shipped pass, with BC2CPP_COVERAGE_ORIGIN_TABLE and the option off (BC2CPP_SITE_ORIGIN_EXCEPTIONS=0) and on (unset). shipped.cxx is byte-identical in both runs. Census sites (2,064; the 280 self receivers and the 7 non-register receivers are not table rows and are the same in both runs):

Receiver status (census sites) Option off Option on (default)
exact 1,801 1,916
ambiguous 103 112
refused 153 29
untagged (register receiver with no tag) 0 0
not_a_register 7 7

Refusal causes: off, query_guarded 139, no_predecessor 11, opaque_reg 3; on, unmodelled:EXCEPT 15, no_predecessor 11, opaque_reg 3. Every site whose status changed went from refused to exact (115) or refused to ambiguous (9); no exact or ambiguous site changed. The 115 new exact origins are constant 50, indexed_result 19, parameter 14, ivar_read 12, embedded_ivar 6, other 6, direct_call_result 5, unknown 2 and literal_or_fresh 1.

Soundness was checked by hand on 10 of the 115 sites, chosen with a fixed seed. For each, the bytecode (the irep, its handler table and the jumps into the range) shows one writer on every path into the send, including the handler path: the handler's target never reaches the send, and no jump enters the send's straight-line run past its writer. Nine are the same walk through a GETCONST, GETIV, SEND0 or GETIDX writer; the tenth receives a block parameter through a MOVE chain, which is ENTRY. That tenth site is classed literal_or_fresh by the entry rule (a non-positional register is treated as the nil every local starts as), which is a label imprecision that predates the option, not a wrong receiver: the value is the method's block argument. The answer does not depend on that label.

The callee frame clobber (not relaxed)

The walk's clobber refusal fires on no census site, before or after the option: the cause column reads zero. Relaxing it for every query (an experiment, not committed) left the whole origin table, the refusal causes included, identical to the option-on table, so the rule frees nothing on this tree. It is also not relaxable soundly in general: the callee of a SEND at R(a) runs in a frame that starts at R(a), so a register above a may hold the callee's locals after the call, and a bytecode method cannot prove the callee does not use them. The send's own register R(a) is already the result (:define), so only registers above a are affected.

BLKCALL (a yield with no keyword or splat arguments, codegen.c emits BLKPUSH/BLKCALL at cursp) is not in CALLEE_FRAME_OPS, though vm.c OP_BLKCALL pushes its callinfo at regs + a (cipush(mrb, a, ...)), so the same overwrite applies. A read above a yield's register is therefore counted as unclobbered. Compiled code never keeps a live register above cursp across a call, so no census site depends on it; adding BLKCALL to the list would change the reaching definitions codegen uses, so it is left for a change of its own.

Follow-up: the remaining no_predecessor and opaque_reg refusals (origin-only)

Measured on the wio closed world, shipped pass, base cfd027ac (origin/master, PR #2065 merged) against the change, both with BC2CPP_COVERAGE_ORIGIN_TABLE. The change's shipped.cxx is byte-identical to the base once the /*SO:*/ joins are stripped, and identical to it with the joins kept. Census sites (the 2,083 by-name rows the census joins to the table):

Refusal cause Before After
query_guarded 134 134
no_predecessor 11 0
opaque_reg 3 2
unmodelled:EXCEPT 0 0
refused 148 136
ambiguous 103 106
exact 1,494 1,503

Table-wide, the refused rows go from 646 to 633.

  • no_predecessor (resolved, 11 sites). A predecessor is dead when no path from the entry reaches it over normal and handler edges. An unguarded instruction with no normal predecessor is one (a handler target is guarded, so a handler edge cannot enter it unguarded), and the walk now skips a dead predecessor of a live query. A dead query keeps the refusal, since it has no value to report. Liveness includes the handler edges, so a node a handler reaches is never called dead (scripts/bc2cpp/origin_remaining_causes_check.rb). Every site was a JMP after a RETURN or RETURN_BLK that no jump targets: Range_each (irep 1056, dead JMPs at 41 and 85) and Array_bsearch_index (dead JMP at 35). Their answers are 8 exact and 3 ambiguous.
  • opaque_reg (resolved, 1 site). A nested block's SETUPVAR writes an outer register only once a closure of the frame exists, and a closure exists only after a BLOCK, LAMBDA, EXEC, CLASS, MODULE or SCLASS of that frame has run. If none of those reaches the query, the register is not opaque for it. Enumerable#drop at index 2 reads the parameter n; its one BLOCK (index 12) comes after the read and the irep has no backward jump, so the answer is the method's entry value (exact, parameter).

Left, with the reason:

  • opaque_reg in RPG2k::Scene::Base#wrap_text_to_width (index 9, register 7 through a MOVE from register 6): the BLOCK at 6 is run by the SENDB each at 7, before the read. Whether the block's SETUPVAR writes register 6 depends on the body of each, which the walk cannot see.
  • opaque_reg in Enumerable#each_slice (index 25, register 5 through a MOVE from register 4): the BLOCK at 22 is run by the SSENDB each at 23, before the MOVE at 24 reads register 4 (ary). The same call the walk cannot see.
  • unmodelled:EXCEPT: no census site on this master has it. The EXCEPT refusals are now query_guarded at the handler target, which the normal walk does not cross. Crossing one needs the handler entry value (the protected range's writes), which is what BC2CPP_SITE_ORIGIN_EXCEPTIONS computes; it is not a separate case to resolve here.
  • query_guarded (134 sites) is not one of these causes and is unchanged.

Checks run on the change: bc2cpp_origin_remaining_causes_check.rb (new; its positive cases fail on master's walk), bc2cpp_origin_transfers_check.rb, bc2cpp_site_origin_exceptions_check.rb, bc2cpp_bytecode_ir_dataflow_check.rb and bc2cpp_site_profile_check.rb (both with MRBC), bc2cpp_bytecode_ir_check.rb, bc2cpp_join_dominance_check.rb and bc2cpp_irep_scans_check.rb.

Follow-up: ENTER slots, EXCEPT pass-through and join may-sets (ADR 0379, origin-only)

Measured on the wio closed world, shipped pass, base 5ddf1d98 against the change, both with BC2CPP_COVERAGE_ORIGIN_TABLE. shipped.cxx is byte-identical (tags included). Census sites (2,060):

Receiver origin unknown, by sub-reason Before After
refused unmodelled:ENTER (R1 of a positional method without keywords) 271 0
refused unmodelled:EXCEPT 15 0
refused opaque_reg 2 2
ambiguous 97 0
exact, definition unclassified 135 0
not a register 7 7
unknown 527 9

The 128 ambiguous sites (97 before, 31 more that were refused) are each now join (116 sites) or the one origin all definitions share. Join sets: indexed_result|literal_or_fresh 35, literal_or_fresh|parameter 22, direct_call_result|literal_or_fresh 13, ivar_read|parameter 8, indexed_result 7, the rest under 5 each (ruby scripts/bc2cpp/dynamic_site_census.rb ... --origins prints the list). The 135 unclassified exact definitions were SEND0 42, SENDB 28, GETIDX 24, SUB 12, SSEND0 7, DIV 6, MUL 5, SEND 4, ADD 3, GETIDX0 3, SSEND 1; they are call_result, operator_result or indexed_result by the writing op. Table rows: refused 1,811 to 3.

An ambiguous row's category is now the |-joined may-set of its definitions' origins, and its definition column lists the writers. The census adds join, call_result and operator_result origins and an origin_set column in --tsv.

The 7 non-register receivers are the element variable of an inlined &:sym loop (mrb_value bc2cpp_sym_e_<n> = bc2cpp_ary_entry(...), a block-scoped name nothing else assigns); the census now classes them indexed_result (status loop_element) and refuses any other non-register name. unknown is then 2 sites (opaque_reg: a block's SETUPVAR may write the register, which depends on the iterator's body), from 9; shipped.cxx is unchanged.

Follow-up: self in compiled core bodies (ADR 0381)

The implicit_self_unresolved and self-origin sites (about 330) split by body owner. A body of a concrete core class (Array 52, Hash 39, String 35, Range 30 sends with self origin) has a self that is exactly that class once the program has no subclass of it; ADR 0381 proves that with a checked site (class test, bc2cpp_guard_violation else). Wio shipped build, switch off against on, same tree (BC2CPP_CORE_SELF_EXACT=0):

Measure Off On Delta
bc2cpp_send( occurrences 2,082 2,028 -54
CORE_BODY_EXACT_CHECKED sites 38 95 +57
generated C++ 21,553,927 bytes 21,558,616 bytes +4,689

Module-owned bodies (Enumerable 43 bodies and 2,156 lines for 11 includers, Comparable, Kernel, Enumerator) are not cloned per includer: the sites are to_enum (no compiled target, 66 in all) and each funcalls that keep their funcall arm. What is left in the concrete-class owners is to_enum 39, and natives with no expression body (replace 10, byteslice 6, delete 4, index 3, __empty_range? 4).

Follow-up: send-argument facts for pack and unpack

The escape report's largest blocker after send_argument is the argument of a native. A send that passes a tracked value as an argument is refused unless NATIVE_ARG_OK audits that position (see bc2cpp-ir-nil-escape.md). Rejections by name, from one instrumented census pass (each rejection is counted, so the counts exceed the number of sites): puts 414 (receiver unknown; the Ruby puts of StringIO and RGSS::ErrorReport::Tee is in the callee list), raise 224 (the message is kept by the exception), new 220 (Class#new forwards to initialize), []= 144 and <</push 162 (stores), + 72 (receiver unknown; natives coerce the argument), == 54 (receiver is a Comparable, so the Ruby Comparable#== is a callee), draw_text 54 (RGSS natives), pack 40, join 10, include? 4, fetch 2. The comparison operators have almost no tracked argument (<= and >= twice each, < and > never), so no comparison fact was added.

pack and unpack are the chosen names. The template is read once (prepare_tmpl, mrb_get_args "S") into a stack struct that dies with the call, and the results are fresh copies (pack.c). Only an exact String template is covered: any other class reaches mrb_ensure_string_type, which inspects it (%Y). The element conversions (to_int, to_f) run on the receiver's elements, and a template is among them only if it was stored in an array, a store the analysis already refuses. unpack1 is registered by the same file but has no sites in the census, so it is left out. The by-name audit (native_files_audited?) holds, and no Ruby definition of either name appears in the reject log.

Measure (census, shipped pass, master 4a7ceb5) Master Branch Delta
send_argument escapes, all kinds 917 894 -23
send_argument escapes, String sites 598 575 -23
confined String sites (engine) 826 849 +23
shipped.cxx (paths normalised) byte-identical

The receiver of pack (usually an array literal) still escapes with send_receiver, since pack has no NATIVE_RECEIVER_OK fact. The flipped sites do not change the shipped C++. Verified by scripts/bc2cpp/send_arg_facts_check.rb: the intended shapes are confined, a Ruby pack on an exact receiver, an unknown receiver and a user-class template keep their templates, and the compiled and interpreted runs agree.

Computed-name sends (COMPUTED_SEND)

A __send__(name, ...) whose name register provably holds one of a finite set of Symbols is expanded into a direct call per name (tools/bc2cpp/codegen_computed_send.rb, computed_send_names.rb). The proof follows reaching definitions, so copies and case/?: joins are covered. Proven: a frozen constant table of Symbols indexed by a register, a copy of that element, a join of Symbol literals and a frozen Hash table. Refused: a name that is a method parameter, a computed String, and a table element that is a constant rather than a Symbol literal. scripts/bc2cpp/computed_send_check.rb checks each shape against the generated C++, and BC2CPP_COMPUTED_SEND=0 keeps every computed send by name.

send is treated like __send__ when the closed world shows Kernel's native (mruby-metaprog) is its only definition.

Unproven names on self (COMPUTED_SEND_GUARDED)

A computed send on self whose name the proof cannot bound still tries the likely names first. These are the names the class of self defines in bytecode, that the program spells as Symbols (DynamicNames), and whose arity accepts the call's argument count. Each one becomes a Symbol compare and a direct call, compiled as the literal send would be, and a name is kept only when that call is direct. Any other value, including a non-Symbol, takes the original send, so nothing is proven or assumed about the name. send(*args) reads the name and the arguments out of the packed Array and picks the arms by its length. Optcarrot's send(*DISPATCH[@opcode]) and its wrappers' send(mode, ...)/send(instr) take this form. At most GUARDED_MAX_ARMS (96) names are tried per site. BC2CPP_COMPUTED_SEND_GUARDED=0 turns the form off.

SPLAT_SHARED: send(*list) compiles to LOADNIL R; ...; ARYCAT R (S); SSEND, and ARYCAT copies S into a new Array on every call. When the guarded splat takes the send, the method loop (codegen_method.rb) hands it the ARYCAT's text instead (splat_shared_pair?). The send then reads the name and the arguments from S in place when S is an Array no longer than the longest arm's list and every element is an immediate, which needs no GC root once read. Any other S is copied first, as ARYCAT did, and the original send always gets its copy. The pair is taken only when the list register still holds its LOADNIL's nil (the instructions between write only registers above it), nothing jumps into the three instructions, no glue or prefix covers them, and the send's text is the guarded expansion itself. Optcarrot's send(*DISPATCH[@opcode]) stops allocating an Array per emulated instruction, which removes the steady-state GC: 180 frames went from 3.47 s to 3.10 s wall. BC2CPP_SPLAT_SHARED=0 keeps the copy. A send(*table[i]) also remembers its arm per index (SPLAT_ROW_MEMO, docs/bc2cpp-splat-row-memo.md).

Follow-up: literal-default optional constructor positions (CONSTRUCTOR_OPTIONALS)

CONSTRUCTOR_POOLS pooled only the mandatory positions of an initialize, because a default could read a later parameter's register before ENTER's caller value or its own default is there. ctor:optional_position was then the largest single cause in BC2CPP_POOL_DROP_REPORT (57 dropped pools). An optional position is now pooled when every default of that initialize is one literal load into its own register (= nil, false, 0, "x", a Symbol, read from the JMP table after ENTER). Each position joins only the sites that pass it, and the flow already routes an omitted position through its default. The positions stay out when no site passes them, or when the initialize has no keyword parameters and some site passes keywords, since ENTER appends those as one more positional Hash. An initialize without mandatory positions keeps refusing RBS_CHECKED_PARAMS and never reports :no_sites (ENTRY_SEEDS' "no Ruby caller"), because a zero-argument new is no site. BC2CPP_CTOR_OPTIONALS=0 restores the old rule; checked by scripts/bc2cpp/constructor_optionals_check.rb.

On the closed world, switch on against off on one tree: dropped pools 583 to 572 (ctor:optional_position 57 to 49), ivar class pools 369 to 380, pooled constructors 45 to 50. The new pools include Game::Battle#@rng (exactly Game::Rng), its four boolean flags, @ai, @attributes, @states and three scenes' @rng. In the shipped C++, 98 runtime class tests (mrb_obj_class arms) go, about 20 of them in Game::Battle's random-number paths, with 8 more NILABLE_RECEIVER nil arms and 2 more guard violations; generated lines go from 410,097 to 409,923. By-name sends do not change (761 bc2cpp_send, 374 mrb_funcall_with_block). Game::Battle::Combatant#@states stays unpooled: its attr writer still makes the pool structural.

Follow-up: setter names out of reach of computed sends (SETTER_NAME_CLOSURE)

DynamicNames.universe used to add stem= for every spelled stem once any send computed its name, so every setter counted as callable by a name nobody spells and SETTER_POOLS stayed CHECKED (a class test and a guard violation). The closed world's only computed calls are core Symbol#to_proc, Enumerable#inject and Enumerator#each, plus five attr_* lists mrbc packs into a splat. DynamicNames.setter_composition now adds stem= only when a value naming a setter can exist without being spelled: an interpolated Symbol whose last piece is not a literal or ends in =, to_sym/intern outside the shape-keeping core methods, a method-name lister, a definition's name value used other than by a visibility call, a definition op or a self send's result slot, a computed calling send other than core __send__ (send, public_send and method also take a String), or to_enum/enum_for with a non-literal name. When none applies, setter pools for setters the universe leaves out are proofs (no CHECKED). Natives handing Ruby a setter-name Symbol stay outside the model. BC2CPP_SETTER_NAME_CLOSURE=0 restores the blanket rule; checked by scripts/bc2cpp/setter_name_closure_check.rb.

On the closed world the universe goes from 3,272 to 1,637 names. Switch on against off on one tree: 2 bc2cpp_send (631 to 629) and 7 mrb_funcall_with_block (285 to 278) by-name sites and 114 runtime class tests go, with 14 fewer guard violations, 63 more NILABLE_RECEIVER nil arms and 20 more bc2cpp_nomethod arms; generated lines go from 410,280 to 409,466, no #error. 100 functions change, mostly in RPG2k::Scene::Map (44) and Game::Interpreter (31). Game::Battle::Combatant#@states stays CHECKED: respond_to?(:states=) spells its setter.

Follow-up: probe-only setter names (PROBE_ONLY in the call-site index)

respond_to?(:states=) spelled states= as a Symbol literal, and entry_arg_call_index (ENTRY_ARG_CALLSITE_PROOF) counted every literal as a possible call, so the setter pools of the names probed this way stayed poisoned. A literal that DynamicNames.probe_only? proves only reaches respond_to?'s name argument is now neither a call site nor a poison, while no Ruby hook can route a probe's name; probe_only? also no longer counts a self send's R[a] (vm.c overwrites it with self) as a use. BC2CPP_PROBE_CALL_INDEX=0 keeps every literal a poison; checked by scripts/bc2cpp/dynamic_names_probe_check.rb.

On the closed world the poisoned setter pools go from 10 to 0 (states=, agi_mod=, atb_gauge=, charged=, ...) and Game::Battle::Combatant#@charged gains a class pool; by-name sends, class tests and guard violations do not change, three [] sites become direct LCF::Array1D#[] calls, and generated lines go from 410,039 to 410,042. Combatant#@states stays OPAQUE for its stored values, not its setter: actor_states's (a.states || []).dup, Game::States.prune's result and b.states - [id] all read @states back, a cycle the pools do not solve.

Follow-up: what drops Combatant#@states (OPERATOR_SENDS, SETTER_RECEIVER_FILTER, PACKED_NEW)

The pools already grow as least fixpoints, so @states reading itself back was never the problem; each store whose value reads OTHER drops the pool on the pass that sees it. Three rules remove three such causes:

  • OPERATOR_SENDS: an ADD/SUB/MUL whose receiver can be no number is the one-argument send codegen compiles it as (compile_operator_fallback), so the exact-class flow gives it that send's class. Array#+ and Array#- get an Array result through NativeCoreDirect::RESULT_ENTRIES, audited like the direct entries down to the allocation from M->array_class but never called directly (their bodies are static). BC2CPP_OPERATOR_SENDS=0; scripts/bc2cpp/operator_sends_check.rb.
  • SETTER_RECEIVER_FILTER: an ivar's setter pool joins only the x= sites whose receiver classes may reach its class family (site_receiver_classes, as POLY_ARG_POOLS); a site no path has reached yet waits for a later pass. BC2CPP_SETTER_RECEIVER_FILTER=0; scripts/bc2cpp/setter_receiver_filter_check.rb.
  • PACKED_NEW: mrbc passes more than 14 positionals as one Array (ARRAY R(a+1) n, SEND Ra :new n=*) and the callee's ENTER unpacks it, so such a new is an n-positional constructor site read at the ARRAY. Combatant.new passes 22, which refused every position of its initialize. BC2CPP_CTOR_PACKED=0; scripts/bc2cpp/constructor_optionals_check.rb.

On the closed world, one tree, all three on against OPERATOR_SENDS off: 1 mrb_funcall_with_block (278 to 277) and 6 runtime class tests (10,107 to 10,101) go, with 26 more NILABLE_RECEIVER nil arms; generated lines go from 409,581 to 409,289, no #error. Combatant#initialize is pooled (8 argument pools) and 6 more Combatant ivars get class pools (@command, @defending, @skip, @attr_ranks, @state_ranks, @state_turns). SETTER_RECEIVER_FILTER alone changes no output. @states still drops: its states constructor argument is actor_states(a), and from_lsd and Party#apply_actor_meta call states= on an element of an actor Array, whose class the exact-class flow does not carry.

Follow-up: pool-drop census corrections (EXACT_ARITH, report labels, Troop#build_member)

The pool-drop report (BC2CPP_POOL_DROP_REPORT=1) ranked integer arithmetic (addi 30, subi 26, add 24, div 7) second among the blockers of the 561 dropped ivar class pools. The exact-class oracle answered op_native? false, so every + - * / was OTHER there even on proven Integers. EXACT_ARITH asks the numeric flow's own gates (numeric_op_native?, numeric_nil_raises?); OPERATOR_SENDS still answers first for a receiver that can be no number. On the closed world 22 more ivar pools (79 pool lines, all INT or INT|NIL) hold and the shipped C++ is byte-identical: the numeric pools already proved those slots, and codegen reads those. Of the dropped pools that remain, about 44 are numeric-proven, so the census now mostly lists real gaps. BC2CPP_EXACT_ARITH=0; scripts/bc2cpp/exact_arith_check.rb.

send_root_entry_reason still labelled every optional initialize position ctor:optional_position and every polymorphic name method:poly, the refusals from before CONSTRUCTOR_OPTIONALS and POLY_ARG_POOLS. An admitted position is now a dropped pool, so the report follows it to the values its sites pass (ctor:optional_position goes from 41 pools to 10, via_ctor_arg:getidx appears with 15).

Game::Troop#member shared its name with a native spelling (Struct) and a Combatant accessor, so no argument pool of member was admitted and the db it passes to Game::Enemy.new had no class. Renamed build_member: the Enemy db position is pooled as LCF::Database, the stats read from the database row get class pools (10 Enemy ivars, 2 Combatant ivars), dropped pools go from 539 to 527, and the C++ loses 1 mrb_funcall_with_block, 2 class tests and 134 lines.

Follow-up: where the runtime class tests come from (RUBY_EXACT_PAST_NATIVE_ARMS, NILABLE_CLASS_TESTS)

Bucketing the 10,067 mrb_obj_class tests of the shipped C++ by the nearest preceding tag comment puts 59% in POLY_SMALL_N chains (a name several classes define, tested arm by arm), 11% in MONO_EMBED_GUARD and 9% in IVAR_ACCESSOR. Of the 2,158 chains, the most common names were x, y, id, width, dispose, color= and hp, and many sat on receivers the exact-class flow already proves (@state.x, @state exactly Game::State or nil).

Two causes:

  • A zero-argument name an RGSS native also answers (NATIVE_WRAPPER_ZERO_ARG_DIRECT: x, y, width, height, dispose, ...) went to the native-arm block before any receiver proof was read, and that block's fallback is the chain. RUBY_EXACT_PAST_NATIVE_ARMS sends a receiver the flow proves to be one Ruby class (ruby_exact_receiver?) past the native arms, which test only exact native classes, to the exact-class path. BC2CPP_RUBY_EXACT_PAST_NATIVE=0.
  • NILABLE_RECEIVER compiled the nil test and the exact-K path only when that shed a by-name dispatch. A chain whose fallback raises (bc2cpp_nomethod) has none to shed, so a nil-or-K receiver kept its chain. NILABLE_CLASS_TESTS also takes the nil test when it removes class tests and adds no dispatch. BC2CPP_NILABLE_CLASS_TESTS=0.

Checked by scripts/bc2cpp/nilable_class_tests_check.rb. On the closed world, one tree each:

before RUBY_EXACT_PAST_NATIVE_ARMS + NILABLE_CLASS_TESTS
mrb_obj_class class tests 10,067 9,801 7,515
bc2cpp_send by-name sends 104 39 39
POLY_SMALL_N chains 2,158 2,145 1,824
NILABLE_RECEIVER sites 715 718 1,591
bc2cpp_nomethod arms 4,216 4,199 3,326
generated lines 409,027 408,542 405,665

No #error; mrb_funcall_with_block and guard violations do not change.

Follow-up: argument pools of methods with optional parameters (OPTIONAL_ARG_POOLS)

Joining the shipped POLY_SMALL_N chains to the argument pools their receivers come from showed the pools of the top method by class tests, Game::Battle#apply_skill_hit (146 tests over 51 sites), dropping on b and cmd, which apply_command(b, combo = 1) and apply_command_all(b, cmd, combo = 1) hand on through their combo.times blocks. Neither caller had pools at all: admission rule 6 refused any method with an optional parameter. With no post parameter, OP_ENTER binds R[1..mand] from the first mand arguments whatever the count, so OPTIONAL_ARG_POOLS admits such a method for its mandatory positions, from sites passing mand to mand+opt arguments. The fixnum entry proof keeps the pure shape. BC2CPP_OPTIONAL_ARG_POOLS=0; scripts/bc2cpp/optional_arg_pools_check.rb.

On the closed world, one tree, against BC2CPP_OPTIONAL_ARG_POOLS=0: 35 more argument pools (342 to 377; e.g. apply_skill_hit's cmd, Party#cast_escape_skill's state, Scene::Base#draw_actor_state's bitmap), 54 fewer runtime class tests (7,644 to 7,590), 20 fewer POLY_SMALL_N chains (1,821 to 1,801), 49 more NILABLE_RECEIVER sites and 26 fewer generated lines; no #error, and by-name sends and guard violations do not change. apply_skill_hit's battlers still drop: target is a cmd[:target] Hash read, and b comes from apply_command's own callers.

Symbol literals passed to public/private (public :char_passable?) also poison names for these proofs. Leaving them out changes no generated code, because mruby-rpgxp redefines both in Ruby and the freed names take their arguments from callers whose pools drop anyway.

Follow-up: nested block literal receivers

BLOCK_ITERATOR_DIRECT reads the literal receiver from the original parent irep of a nested block, including String, Array, Hash and Range literals. When the program excludes singleton-method installation, these heap literals select one compiled core arm without a runtime class test. Integer literals retain their existing proof. A block that can transfer control still keeps the root-context guard and its fallback. scripts/bc2cpp/block_iterator_direct_check.rb compares nested String iteration and break behavior against interpreted mruby.

Follow-up: boxed literals in static owner arms

STATIC_OWNER_ARMS recognizes boxed Integer, boolean and nil literals as well as local variables in owner-call arguments. Calls such as rows[0] can therefore call compiled Ruby owners directly behind the existing exact-class tests; other receivers retain the original owner call. The accepted argument grammar excludes arbitrary expressions and allocations, and the arity and visibility checks are unchanged. scripts/bc2cpp/static_owner_dispatch_check.rb checks positive and negative integers, booleans and nil against interpreted mruby on inherited Ruby classes, singleton objects, native Arrays and nil receivers.

On the current shipped closed world, STATIC_OWNER_ARMS sites increase from 210 to 212: Game::Picture#restore_legacy_lsd and Game::State#to_lsd each gain six exact-class [] arms for LCF owners. The owner-call fallbacks remain (1,950), and matching receivers avoid their method lookup. Neither generated file has #error. Counts come directly from the saved shipped C++ because the census summary still expects the obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: Ruby receivers before native wrappers with arguments

RUBY_EXACT_PAST_NATIVE_ARMS also applies to the Bitmap drawing wrappers and the openness=, opacity= and tone= setters. When the flow proves the receiver is exactly a program-defined Ruby class outside the RGSS native classes, none of the native identity tests can match. The send proceeds to the existing Ruby dispatch proofs instead. Unproven receivers retain the native tests and fallback. BC2CPP_RUBY_EXACT_PAST_NATIVE_ARGS=0 restores the argument-taking native arms; BC2CPP_RUBY_EXACT_PAST_NATIVE=0 disables both this and the zero-argument rule. scripts/bc2cpp/nilable_class_tests_check.rb checks both switches, direct Ruby calls and the unproven-receiver fallback.

On the current closed world, the argument-only switch produces byte-identical shipped C++: this extends compiler coverage but does not reduce that game's current dispatch count. The focused fixtures remove native class tests and fallback sends for eight proven Ruby call shapes. Both generated shipped files contain no #error. The report formatter currently fails after writing those files because its expected INTEGER_CONSTANT_PROOF diagnostic heading is absent; the comparison uses the saved C++ directly.

Follow-up: proven Symbol arguments to respond_to?

SYMBOL_PROBE_DIRECT uses the IR's exact-literal type proof for the name argument of respond_to?, including Symbol-only control-flow joins and shifted registers inside inlined blocks. With OWNER_SET_DIRECT's existing audits and no overriding respond_to? or respond_to_missing?, a proven Symbol needs neither a type test nor the owner-call fallback. A world with bare BasicObject descendants keeps its Kernel receiver guard. Mixed String/Symbol arguments retain the original path. BC2CPP_SYMBOL_PROBE_DIRECT=0 restores the argument guard; disabling OWNER_SET_DIRECT disables this proof too. The owner-set fixture compares interpreted and compiled behavior and checks withdrawal for Ruby overrides.

On the shipped closed world, 217 Symbol probes use this proof: respond_to? owner-call fallbacks fall from 218 to 1, total owner-call sites from 1,950 to 1,733, and Symbol type tests from 1,186 to 969. Generated lines fall from 411,802 to 410,710; neither file has #error. These counts use the saved C++ directly because the report formatter still expects the obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: stable class/module arguments to Kernel predicates

KERNEL_DIRECT omits the class/module argument guard for is_a? and kind_of? when stable_constant_register proves the argument names a stable class or module. This reuses the binding proof already used for constructor identity; an annotation or class-name hint is insufficient. Dynamic arguments, non-class constants and reassigned bindings keep their type checks and error behavior. The audited Kernel-body and receiver proofs remain required, including the Kernel receiver guard in an unsealed BasicObject world. BC2CPP_KERNEL_CLASS_ARG_PROOF=0 restores the argument guards. The owner-set fixture checks the disable switch, reassigned constants, overrides and runtime parity for valid and invalid arguments.

On the current shipped closed world, enabling the proof removes 63 argument guards and owner-call fallbacks: is_a? fallbacks fall from 69 to 7 and kind_of? from 16 to 15. Total owner-call sites fall from 1,733 to 1,670, mrb_class_p tests from 181 to 118, and generated lines from 410,742 to 410,490. Neither generated file has #error. Counts use saved C++ from on/off runs because the census formatter still expects the obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: proven Integer arguments of audited core calls

NATIVE_CORE_EXACT reuses native_int_arg_proven? for the Integer arguments of String#getbyte, String#to_i(base), String#byteslice and Array#__fetch. The Fixnum proof is narrower than their native Integer guard, so an exact receiver plus proven arguments needs no dispatch fallback. For a two-argument byteslice with only one argument proven, only the other argument is tested. The helpers retain bounds checks, radix validation and their original errors; unproven argument types retain the normal guarded path. Checked core-body receiver proofs admit only independent Integer literal proofs within the narrowest target Fixnum range; they retain the checked receiver guard. BC2CPP_CORE_INT_ARG_PROOF=0 restores the old guards. The owner-set fixture compares compiled and interpreted values/errors, including invalid radices, non-Integer arguments and oversized integers.

On the shipped closed world, the proof removes four Integer tests (3,730 to 3,726), in String#upto, String#__upto_endless, String#gsub and String#sub. Two calls to to_i(10) become exact native calls, reducing native-owner fallback sites from 87 to 85. All 127 checked core receiver guards remain; owner-call sites stay at 1,670. Generated lines fall from 410,490 to 410,478, with no #error. Counts use saved C++ because the census formatter still expects the obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: exact receiver flow for monomorphic embedded-object calls

MONO_EXACT_FLOW removes MONO_EMBED_GUARD when the existing exact receiver flow proves precisely the accepted target owner's class. It reads the original unshifted receiver register and requires an ordinary explicit receiver site; substituted receivers/arguments retain their previous paths. The exact-flow singleton and captured-register safety gates remain in force. A nilable flow qualifies only inside an already checked non-nil arm, so its nil behavior is preserved. A subclass or another class does not satisfy the owner identity. BC2CPP_MONO_EXACT_FLOW=0 restores the guard. The nilable-class fixture checks normal, inlined-block and rescue calls, singleton-proof withdrawal, retained unknown/nil paths and compiled-versus-interpreted runtime results.

On the shipped closed world, MONO_EMBED_GUARD sites fall from 1,074 to 831: 243 MONO_EXACT_FLOW calls replace class guards and their unreachable nomethod fallbacks. mrb_obj_class queries fall from 7,355 to 7,112 and nomethod calls from 3,269 to 3,026. NILABLE_RECEIVER sites increase from 1,698 to 1,793: the now-cheaper non-nil arm makes 95 more explicit nil checks worthwhile. Owner-call sites remain at 1,670. Generated lines fall from 410,478 to 409,957, with no #error in either output. These emitted counts exceed the source-site profile's candidates because block copies and checked non-nil contexts also benefit. Counts use saved on/off C++ because the census summary still expects the obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: constructed objects in record-hash read flow

RECORD_OBJECT_READS extends RECORD_NILABLE_READ to object classes whose every listed record writer passes the existing record_writer_exact_class proof. That proof accepts literal heap objects or stable standard constructors; a factory call with an unproven result does not qualify. The resulting class bits flow through argument/return and ivar pools instead of helping only a local record receiver. Missing/deleted keys and explicit nil writers retain nil in the class set. Escaped records and unknown writers remain unproven, and numeric classes are not introduced into the exact flow by this rule. BC2CPP_RECORD_OBJECT_READS=0 restores the previous read flow. The record-read fixture checks pooling, truthiness narrowing, deletion, nil stores, escapes and compiled-versus-interpreted values/errors.

On the synced shipped baseline 6f9b665b5, on/off generation removes 246 class queries (7,112 to 6,866) and 35 owner-call sites (1,670 to 1,635). NILABLE_RECEIVER sites increase from 1,793 to 1,910, preserving missing/deleted-key behavior with explicit nil checks. Generated lines fall from 409,957 to 408,970; neither output has #error. Dropped class pools fall from 509 to 508. These figures are measured independently of NATIVE_EXACT_BEFORE_POLY and must not be added to its savings without a combined run. The census formatter still fails after saving the complete output on its obsolete INTEGER_CONSTANT_PROOF heading.

Follow-up: exact native receivers before Ruby class chains

NATIVE_EXACT_BEFORE_POLY tries the existing NATIVE_DIRECT_EXACT plan before building a POLY_SMALL_N chain for an ordinary explicit receiver with an exact RGSS native-class proof. Ruby arms for other classes cannot match that receiver. The native registration, owner/mixin and argument-conversion gates are reused; an override withdraws the arm, and unproven receiver classes retain their chains. Substituted receivers or arguments are excluded. The native call still performs its existing coercions, including the Integer check/fallback when unboxing is disabled. BC2CPP_NATIVE_EXACT_BEFORE_POLY=0 restores the original ordering. The nilable-class fixture checks native setters, shifted block code, unknown receivers, setter overrides and disabled-unboxing behavior.

On the current shipped closed world, the reordered proof removes 32 POLY_SMALL_N chains (1,766 to 1,734) and 73 mrb_obj_class queries (7,112 to 7,039). Owner-call sites remain at 1,670: the eliminated branches preceded native calls that were already direct. Generated lines fall from 409,957 to 409,631, with no #error in either output. Emitted counts include block copies and can exceed the profile's 26 source candidates. Counts use saved on/off C++ because the census summary still expects the obsolete INTEGER_CONSTANT_PROOF heading. Indexed container results (42 blocked class pools in the profile) remain the next inference target; numeric guards still require independent width/range proofs.