Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Consolidate repeated feedback-table and baseline-tier lookups while preserving feedback versions, capacity behavior, and publication ordering.

- JIT M3: cache one idle native entry handle per runtime with epoch-based
invalidation (ABI minor 20), remove native-return queue allocation, and reuse
CALL feedback storage. Reuse validated bytecode boundaries during helper PC
Expand Down
10,657 changes: 10,657 additions & 0 deletions benchmarks/results/m3-feedback-lookups-call-heavy-paired.json

Large diffs are not rendered by default.

10,657 changes: 10,657 additions & 0 deletions benchmarks/results/m3-feedback-lookups-fibonacci-recursive-paired.json

Large diffs are not rendered by default.

10,657 changes: 10,657 additions & 0 deletions benchmarks/results/m3-feedback-lookups-generic-call-entry-paired.json

Large diffs are not rendered by default.

10,657 changes: 10,657 additions & 0 deletions benchmarks/results/m3-feedback-lookups-property-heavy-paired.json

Large diffs are not rendered by default.

10,657 changes: 10,657 additions & 0 deletions benchmarks/results/m3-feedback-lookups-scalar-loop-paired.json

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions benchmarks/results/m3-property-helper-profile.json

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions docs/M3.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,90 @@ execution orders, and exact harness source are archived together:
The per-call bookkeeping target remains active. Repeated coordinator tier-state
queries and repeated B-tree searches in feedback observation are the next
identified costs; no implementation of those follow-ups is included here.


### Feedback and tier-query consolidation

The follow-up to timing-stack commit `35edb4c` uses a single B-tree entry
lookup in `observe_type` and `observe_call`. Type capacity is checked only for
vacant entries; occupied feedback still widens at capacity. Rejected entries
retain saturating drop accounting without advancing the lattice version.
Call feedback remains independent of type capacity and continues growing
argument slots after arity/type megamorphism. Each admitted lattice change
retains its exact wrapping version update and generation-specific identity.

`record_hot` captures baseline tier state once after maintenance, which can
publish code. Feedback observation cannot mutate that state, and a successful
direct refresh returns immediately. This removes up to two repeated queries
without caching across callbacks or publication boundaries.

Three new characterization tests cover occupied-at-capacity updates, repeated
vacant rejection, exact stable/widened versions, generation separation,
zero-capacity call admission, and continued slot growth. Deliberately rejecting
occupied entries at capacity and stopping at megamorphic arity each make the
corresponding regression fail. All 518 release runtime tests and 82 targeted
AddressSanitizer/LeakSanitizer tests pass, along with workspace Clippy.
Independent review found no remaining correctness issue.

The five final paired comparisons use merged M3 `a38bd01` as baseline and
identical corrected harnesses. The candidate combines timing-stack and lookup
changes; these figures do not isolate the contribution of either change.
Each mode has five discarded warmups, 30 fresh-process pairs, ten throughput
windows of at least one second per engine, and 10,000 paired bootstrap
resamples. Engine order is independently balanced (15/15 latency, 5/5 windows)
on CPU 0. Checksums, native entry/exit balance, source and executable hashes
were audited before archiving.

| Workload / mode | Speed relative to a38bd01 | Paired 95% interval |
|---|---:|---:|
| Generic call / Tier 1 | 1.193x | 1.190x–1.197x |
| Generic call / requested Tier 2, actually Tier 1 | 1.163x | 1.160x–1.165x |
| Generic call / automatic, actually Tier 1 | 1.181x | 1.175x–1.185x |
| Recursive Fibonacci / Tier 1 | 1.187x | 1.183x–1.189x |
| Scalar loop / Tier 2 | 1.035x | 1.028x–1.038x |
| Scalar loop / automatic | 1.041x | 1.037x–1.047x |

All interpreter, direct-call and property-access comparisons are statistically
tied. Direct-call Tier 2 ranges from 1.9% slower to 0.2% faster; property
automatic from 0.2% slower to 0.2% faster. Scalar Tier 1 is also tied, between
2.3% slower and 0.6% faster. Automatic recursion still has zero native entries
and is an interpreter-path control. The scalar speed changes have no isolated
causal attribution. These dirty-source revision diagnostics do not replace the
complete clean-source matrix or prove an overall no-regression result.

Raw paired reports, throughput samples and exact driver/harness source:

- [Generic calls](../benchmarks/results/m3-feedback-lookups-generic-call-entry-paired.json)
- [Direct calls](../benchmarks/results/m3-feedback-lookups-call-heavy-paired.json)
- [Recursive Fibonacci](../benchmarks/results/m3-feedback-lookups-fibonacci-recursive-paired.json)
- [Scalar loop](../benchmarks/results/m3-feedback-lookups-scalar-loop-paired.json)
- [Property access](../benchmarks/results/m3-feedback-lookups-property-heavy-paired.json)

### Property helper profile and next change

The [property profile](../benchmarks/results/m3-property-helper-profile.json)
uses the same kernel with 40,000 iterations instead of 2,000, retaining Int32
arithmetic. This is diagnostic sampling including setup and warmup, not a
performance acceptance result. Self attribution is 55.82% helper-frame
validation, 7.02% shape guard, 6.74% Free, and 3.95% MaterializeOwner. The worker
records 15 Tier 2 entries, zero deopts/retries/fallbacks, and balanced entry/exit
counts; these counters include warmup.

Optimized property access currently materializes borrowed stack aliases before
every shape guard and releases the temporary owners afterward. The guard can
instead inspect an already-rooted argument/local slot for a narrowly proven
borrowed receiver. This next change is not implemented yet. It must admit only
live stacks made entirely of immediate primitives or valid argument/local
aliases, synchronize canonical arguments/locals and PC, and expose an empty
operand stack during the nonallocating/nonreentrant shape guard. Any OwnedSlot
or Unknown provenance must retain existing lowering. On guard exception the
empty stack is cleanup-safe; on shape/value-tag miss the existing full
materialization bridge must reconstruct the original pre-operation stack.
Success can avoid temporary ownership while preserving each exact shape and
generation check. Do not replace this with a pointer-only cache across effects.

Required follow-up coverage includes steady helper counts, shape and value-tag
misses below another receiver alias, safe guard-rejection cleanup, alias
reassignment, deeper owned values, and the existing polymorphic/stress-GC tests.
The original objective still requires this property work and a fresh complete
matrix; the lookup cleanup is not completion of the broader performance target.
34 changes: 34 additions & 0 deletions docs/NEXT_SESSION_GOAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,37 @@ Next read-only investigations found two bounded opportunities, not implemented:
table capacity. Add focused version/full-capacity/zero-arity/widening coverage
before changing these routines. Keep every admitted observation and exact
generation identity; do not skip feedback simply because timing is expensive.


Follow-up on perf/m3-feedback-lookups (parent 35edb4c / PR #19):
- observe_type/observe_call now use one B-tree entry search; record_hot reuses
baseline state captured after maintenance. Three added characterization tests
preserve exact versions, capacity/rejections, generation identity, independent
call capacity and megamorphic slot growth. Capacity/arity mutant checks fail
as intended. 518 release tests, 82 ASAN/LSAN tests and workspace Clippy pass.
Independent review found no issue.
- Property profiling is archived in m3-property-helper-profile.json. The enlarged
automatic probe attributes 55.82% self cycles to helper frame validation,
7.02% shape guard, 6.74% Free, 3.95% MaterializeOwner; it includes warmup.
- Next priority is optimized property ownership: use a proven argument/local
owner directly for the nonallocating shape guard only when ALL live stack
provenance is immediate primitive or argument/local alias. Spill canonical
args/locals and PC, expose stack_top=base. Guard exceptions return with the
empty cleanup-safe stack; misses/tag mismatches first materialize the full
original stack with the existing bridge. Successful hits need no temporary
owners or corresponding frees. Preserve every shape-generation check.
OwnedSlot/Unknown anywhere on the live stack keeps current lowering; OSR
without an explicit owner proof must do the same. See M3.md for tests.
- This property optimization and the complete fresh matrix are not implemented
or completed by the lookup cleanup. Continue toward the pasted-file objective.

Final query-stage measurements are complete (no live benchmark process):
`benchmarks/results/m3-feedback-lookups-*-paired.json` covers property,
generic calls, direct calls, recursion and scalar loops at full 5/30/10 settings.
Baseline is a38bd01 with the identical corrected harness; candidate includes
both timing stack and query consolidation. Generic Tier1 1.193x (CI1.190–1.197),
generic automatic 1.181x (1.175–1.185), recursive Tier1 1.187x (1.183–1.189).
All direct/property/interpreter comparisons are tied. Scalar Tier2 1.035x and
automatic 1.041x have no isolated causal attribution. All provenance/order/sample
qualification checks passed; diagnostics remain dirty-source, not full-matrix
acceptance. Next implementation priority remains the property ownership path.
16 changes: 7 additions & 9 deletions jit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1836,6 +1836,9 @@ unsafe impl rquickjs_core::runtime::JitBackend for ProductionBackend {
return 2;
}
self.maintenance_if_due(false);
// Maintenance may publish code. Feedback below cannot change this state;
// a successful direct refresh returns before the cached value is reused.
let baseline_state = self.coordinator.tier_state(key, runtime::Tier::Baseline);
let observed = match event.feedback_type {
rquickjs_core::qjs::JSJitFeedbackType_JS_JIT_FEEDBACK_INT32 => {
Some(runtime::ObservedType::Int32)
Expand Down Expand Up @@ -1879,10 +1882,7 @@ unsafe impl rquickjs_core::runtime::JitBackend for ProductionBackend {
}
let probe_inputs = (self.feedback.version(), self.coordinator.installed_count());
if self.config.tier_policy() == JitTierPolicy::BaselineOnly
&& matches!(
self.coordinator.tier_state(key, runtime::Tier::Baseline),
runtime::CompileState::Installed(_)
)
&& matches!(baseline_state, runtime::CompileState::Installed(_))
&& self.direct_refresh_probes.get(&key) != Some(&probe_inputs)
{
// The readiness answer depends only on the feedback lattice and
Expand All @@ -1902,10 +1902,8 @@ unsafe impl rquickjs_core::runtime::JitBackend for ProductionBackend {
return 1;
}
}
if matches!(
self.coordinator.tier_state(key, runtime::Tier::Baseline),
runtime::CompileState::Installed(_)
) || self.profitability_blacklisted.contains(&key)
if matches!(baseline_state, runtime::CompileState::Installed(_))
|| self.profitability_blacklisted.contains(&key)
{
let optimizing_ready = match self.coordinator.tier_state(key, runtime::Tier::Optimizing)
{
Expand Down Expand Up @@ -1944,7 +1942,7 @@ unsafe impl rquickjs_core::runtime::JitBackend for ProductionBackend {
return 0;
}
if matches!(
self.coordinator.tier_state(key, runtime::Tier::Baseline),
baseline_state,
runtime::CompileState::Blacklisted | runtime::CompileState::Installed(_)
) {
self.requested.insert(key);
Expand Down
31 changes: 19 additions & 12 deletions jit/src/runtime/feedback.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{btree_map::Entry as MapEntry, BTreeMap};

use super::FunctionKey;

Expand Down Expand Up @@ -795,8 +795,10 @@ impl FeedbackTable {
}

pub fn observe_call(&mut self, function: FunctionKey, arguments: &[ObservedType]) {
let is_new = !self.calls.contains_key(&function);
let call = self.calls.entry(function).or_default();
let (call, is_new) = match self.calls.entry(function) {
MapEntry::Occupied(entry) => (entry.into_mut(), false),
MapEntry::Vacant(entry) => (entry.insert(CallFeedbackEntry::default()), true),
};
let before = (
call.arities.len(),
call.arity_megamorphic,
Expand Down Expand Up @@ -1028,15 +1030,20 @@ impl FeedbackTable {
observation: ObservedType,
) -> FeedbackState {
let key = FeedbackKey { function, pc, kind };
if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
self.dropped = self.dropped.saturating_add(1);
return FeedbackState::Megamorphic;
}
let before = self.entries.get(&key).map(entry_shape);
let entry = self.entries.entry(key).or_insert_with(|| Entry {
observations: Vec::with_capacity(self.diversity_limit),
megamorphic: false,
});
let at_capacity = self.entries.len() >= self.capacity;
let (entry, before) = match self.entries.entry(key) {
MapEntry::Occupied(entry) => {
let before = entry_shape(entry.get());
(entry.into_mut(), Some(before))
}
MapEntry::Vacant(entry) => {
if at_capacity {
self.dropped = self.dropped.saturating_add(1);
return FeedbackState::Megamorphic;
}
(entry.insert(empty_entry(self.diversity_limit)), None)
}
};
let state = entry.observe(observation, self.diversity_limit);
if before != Some(entry_shape(entry)) {
self.version = self.version.wrapping_add(1);
Expand Down
114 changes: 112 additions & 2 deletions jit/tests/runtime_feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use rquickjs_core::{
runtime::{JitBackend, RuntimeJitGuard},
};
use rquickjs_jit::runtime::{
BinaryFeedbackFlags, FeedbackRepresentation, FeedbackState, FeedbackTable, FunctionKey,
ObservedType, MAX_SPECIALIZED_ARGUMENTS,
BinaryFeedbackFlags, FeedbackKind, FeedbackRepresentation, FeedbackState, FeedbackTable,
FunctionKey, ObservedType, MAX_SPECIALIZED_ARGUMENTS,
};

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -744,3 +744,113 @@ fn call_specialization_rejects_unstable_or_unsupported_signatures() {
.call_specialization_at(caller, 43)
.is_none());
}

#[test]
fn type_feedback_at_capacity_keeps_updates_and_versions_only_lattice_changes() {
let function = FunctionKey::new(501, 7);
let mut feedback = FeedbackTable::new(1, 2);
for (observed, state, version) in [
(ObservedType::Int32, FeedbackState::Monomorphic, 1),
(ObservedType::Int32, FeedbackState::Monomorphic, 1),
(ObservedType::Bool, FeedbackState::Polymorphic, 2),
(ObservedType::Bool, FeedbackState::Polymorphic, 2),
(ObservedType::String, FeedbackState::Megamorphic, 3),
(ObservedType::Int32, FeedbackState::Megamorphic, 3),
] {
assert_eq!(
feedback.observe_type(function, 9, FeedbackKind::Value, observed),
state
);
assert_eq!(feedback.version(), version);
assert_eq!(feedback.dropped_observations(), 0);
if state == FeedbackState::Polymorphic {
assert_eq!(
feedback.snapshot(0).entries()[0].observations(),
&[ObservedType::Int32, ObservedType::Bool]
);
}
}
for (key, pc, kind) in [
(FunctionKey::new(501, 8), 9, FeedbackKind::Value),
(function, 10, FeedbackKind::Value),
(function, 9, FeedbackKind::Exit),
] {
assert_eq!(
feedback.observe_type(key, pc, kind, ObservedType::Int32),
FeedbackState::Megamorphic
);
}
assert_eq!(feedback.len(), 1);
assert_eq!(feedback.version(), 3);
assert_eq!(feedback.dropped_observations(), 3);
let snapshot = feedback.snapshot(0);
let entry = &snapshot.entries()[0];
assert_eq!(entry.function(), function);
assert_eq!(entry.pc(), 9);
assert_eq!(entry.kind(), FeedbackKind::Value);
assert_eq!(entry.state(), FeedbackState::Megamorphic);
assert!(entry.observations().is_empty());
}

#[test]
fn zero_type_capacity_rejects_repeated_types_but_admits_independent_calls() {
let function = FunctionKey::new(502, 1);
let mut feedback = FeedbackTable::new(0, 2);
for dropped in 1..=3 {
assert_eq!(
feedback.observe_type(function, 0, FeedbackKind::Value, ObservedType::Int32),
FeedbackState::Megamorphic
);
assert_eq!(feedback.dropped_observations(), dropped);
assert_eq!(feedback.version(), 0);
assert!(feedback.is_empty());
}
feedback.observe_call(function, &[]);
assert_eq!(feedback.version(), 1);
feedback.observe_call(function, &[]);
assert_eq!(feedback.version(), 1);
let other_generation = FunctionKey::new(502, 2);
feedback.observe_call(other_generation, &[ObservedType::Bool]);
assert_eq!(feedback.version(), 2);
let snapshot = feedback.snapshot(0);
assert_eq!(snapshot.call_argument_types(function), Some(&[][..]));
assert_eq!(
snapshot.call_argument_types(other_generation),
Some(&[ObservedType::Bool][..])
);
assert_eq!(feedback.dropped_observations(), 3);
assert!(feedback.is_empty());
}

#[test]
fn call_feedback_keeps_growing_slots_after_arity_and_types_widen() {
let function = FunctionKey::new(503, 1);
let mut feedback = FeedbackTable::new(0, 1);
feedback.observe_call(function, &[ObservedType::Int32]);
assert_eq!(feedback.version(), 1);
feedback.observe_call(function, &[ObservedType::Int32]);
assert_eq!(feedback.version(), 1);
feedback.observe_call(function, &[ObservedType::Bool, ObservedType::String]);
assert_eq!(feedback.version(), 2);
feedback.observe_call(function, &[ObservedType::Bool, ObservedType::String]);
assert_eq!(feedback.version(), 2);
feedback.observe_call(
function,
&[
ObservedType::Null,
ObservedType::String,
ObservedType::Float64,
],
);
assert_eq!(feedback.version(), 3);
let snapshot = feedback.snapshot(0);
let call = snapshot.call_at(function).unwrap();
assert_eq!(call.state(), FeedbackState::Megamorphic);
assert_eq!(call.argc(), 3);
assert!(call.argument(0).is_empty());
assert_eq!(call.argument(1), &[ObservedType::String]);
assert_eq!(call.argument(2), &[ObservedType::Float64]);
assert_eq!(snapshot.call_argument_types(function), None);
feedback.observe_call(function, &[]);
assert_eq!(feedback.version(), 3);
}
Loading