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.
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 throughbc2cpp_sym_names. The helper definitions are not counted. - Helper region: everything before the first generated
*_implbody. 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_DIAGline 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
categoryin the script). --tsvwrites one row per body site (line, owner method, name, arity, fast-path flag orclass_arm, marker family, guard shape, receiver origin, category,POLY_DIAGpath). The owner tables are a grouping of the owner method's prefix; the "Now" numbers below were produced this way from0a9adfc.
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.
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_sendsites in bodies. The rest is callers that stopped reaching a helper: 268slow_*, 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 (
getidx2,017 callers,slow_add_f712,slow_sub_f471,slow_mul_f442,setidx348,slow_gt311,slow_lt296,slow_div272). 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.
| 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.
| # | 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- The game smoke. It needs the SDL engine and an RPG2000/2003 project
(
data/Nepheshel206beta,data/mtf-meido-action, fetched byscripts/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).
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.tsvOn 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.
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).
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.
- 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_TABLEthe 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,untaggedandnot_a_registercount asunknown, and so does a send whose defining instruction emits several producers (a fast path and its dispatch). - The
unlisted_class_callreasons 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.cxxfrom before the round, not a rebuild of the old commit; its 9,733bc2cpp_sendlines match the figure quoted for it.
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.
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.
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.
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 |
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.
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 aBLOCK. A variable,&blk, an optional value or several writers keep the by-name verdict, and aSEND(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 theto_enumarm 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'sreturns_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
selfis 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 letsEnumerable#collect's ownself.each { }resolve onArray#eachrather than on everyeach.
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.
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.
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 |
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.
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.
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 |
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.
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 |
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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/mapcollector produced.Game::State#to_lsdalone holds 13empty?sites of this kind;block_result_array?already knows those calls return an Array, butexact_core_value_classnever asks. - A method result whose receiver-specific return class exists but is refused.
Game::Variables#to_hreturns@data, a Hash the class layout already knows;codegen_return_classes.rb'sRETURN_CORE_CLASSveto 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 classesRecordHash.analyzealready computes, but which is not an oracle forelement_index_mask.
See native arm Integer guards and ADR 0358.
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.
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.
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.
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.
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).
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.
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.
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).
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 |
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).
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.
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.
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.
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).
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).
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.
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_cellsA 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.
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.
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:selffor register 0,parameterfor a positional register,literal_or_freshotherwise (the nil every local starts as);LOADSELFisself.GETCONST/GETMCNSTareconstant;GETIVisivar_readorembedded_ivarby 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) anduntagged(see "Dispatch-line rows" below for what is left) areunknown.reg_mismatchis 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.
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.
Each by-name line now carries its own tag, /*SO:<label>:<index>:<reg>:<walk>*/:
regis 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_mismatchcannot arise).walkis the register the reaching-definition walk runs on. It isregexcept in a shifted block body (compile_block_body_insn, offset > 0): there the line names the shifted register, and the block's own register isreg - 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_sendreturns untagged (a splat or keyword send, no site) is tagged by thecompile_insnthat 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
untaggedsites above. - 138 in a shifted block body (every such site): 27 were
untagged; 77 movedrefusedtoexact(73) orambiguous(4), which the block's own register answers; 34 stayedexactwith a different origin (literal_or_freshtoparameter,captured_upvarorconstant), 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.
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_indexandmove_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.
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 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.
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 aJMPafter aRETURNorRETURN_BLKthat no jump targets:Range_each(irep 1056, deadJMPs at 41 and 85) andArray_bsearch_index(deadJMPat 35). Their answers are 8exactand 3ambiguous.opaque_reg(resolved, 1 site). A nested block'sSETUPVARwrites an outer register only once a closure of the frame exists, and a closure exists only after aBLOCK,LAMBDA,EXEC,CLASS,MODULEorSCLASSof that frame has run. If none of those reaches the query, the register is not opaque for it.Enumerable#dropat index 2 reads the parametern; its oneBLOCK(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_reginRPG2k::Scene::Base#wrap_text_to_width(index 9, register 7 through a MOVE from register 6): theBLOCKat 6 is run by theSENDBeachat 7, before the read. Whether the block'sSETUPVARwrites register 6 depends on the body ofeach, which the walk cannot see.opaque_reginEnumerable#each_slice(index 25, register 5 through a MOVE from register 4): theBLOCKat 22 is run by theSSENDBeachat 23, before theMOVEat 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 nowquery_guardedat the handler target, which the normal walk does not cross. Crossing one needs the handler entry value (the protected range's writes), which is whatBC2CPP_SITE_ORIGIN_EXCEPTIONScomputes; 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.
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.
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).
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.
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.
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).
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.
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.
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.
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#+andArray#-get an Array result throughNativeCoreDirect::RESULT_ENTRIES, audited like the direct entries down to the allocation fromM->array_classbut 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 anewis an n-positional constructor site read at the ARRAY.Combatant.newpasses 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.