Skip to content
Closed
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
4 changes: 4 additions & 0 deletions changelog.d/8830-declared-map-get-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Speed up declared `Map.get` and `ReadonlyMap.get` calls that pass through
nested interface or object fields. Genuine native Maps now bypass generic
method dispatch, while structural values, subclasses, proxies, primitives,
and nullish receivers retain ordinary JavaScript behavior on a brand miss.
118 changes: 117 additions & 1 deletion crates/perry-codegen/src/expr/readonly_collection_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::{compile_module, CompileOptions, ImportedClass};
use perry_hir::types::Type;
use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt};
use perry_hir::{
Class, ClassField, Expr, Function, Interface, InterfaceProperty, Module, Param, Stmt,
};

fn number_param(id: u32, name: &str) -> Param {
Param {
Expand Down Expand Up @@ -223,6 +225,105 @@ fn compile_imported_has_ir() -> String {
.expect("LLVM IR is UTF-8")
}

fn compile_nested_map_get_ir() -> String {
let mut module = Module::new("command_executor.ts");
module.interfaces.push(Interface {
id: 1,
name: "CommandExecutorContext".to_string(),
type_params: Vec::new(),
extends: Vec::new(),
properties: vec![InterfaceProperty {
name: "entityToArchetype".to_string(),
ty: Type::Generic {
base: "Map".to_string(),
type_args: vec![Type::Number, Type::Number],
},
optional: false,
readonly: false,
}],
methods: Vec::new(),
is_exported: false,
});
module.classes.push(Class {
id: 2,
name: "CommandExecutor".to_string(),
type_params: Vec::new(),
extends: None,
extends_name: None,
native_extends: None,
extends_expr: None,
heritage_lexically_shadowed: false,
fields: vec![ClassField {
name: "ctx".to_string(),
key_expr: None,
ty: Type::Named("CommandExecutorContext".to_string()),
init: None,
is_private: false,
is_readonly: true,
decorators: Vec::new(),
}],
constructor: None,
methods: vec![Function {
id: 3,
name: "lookup".to_string(),
type_params: Vec::new(),
params: vec![number_param(1, "entityId")],
return_type: Type::Number,
body: vec![Stmt::Return(Some(Expr::Call {
callee: Box::new(Expr::PropertyGet {
object: Box::new(Expr::PropertyGet {
object: Box::new(Expr::PropertyGet {
object: Box::new(Expr::This),
property: "ctx".to_string(),
byte_offset: 0,
}),
property: "entityToArchetype".to_string(),
byte_offset: 0,
}),
property: "get".to_string(),
byte_offset: 0,
}),
args: vec![Expr::LocalGet(1)],
type_args: Vec::new(),
byte_offset: 0,
}))],
is_async: false,
is_generator: false,
is_strict: true,
is_exported: false,
captures: Vec::new(),
decorators: Vec::new(),
was_plain_async: false,
was_unrolled: false,
}],
getters: Vec::new(),
setters: Vec::new(),
static_accessor_names: Vec::new(),
static_accessor_fn_ids: Vec::new(),
computed_members: Vec::new(),
static_fields: Vec::new(),
static_methods: Vec::new(),
decorators: Vec::new(),
is_exported: false,
aliases: Vec::new(),
is_nested: false,
alloc_width_hint: 0,
specialized_from: None,
});

String::from_utf8(
compile_module(
&module,
CompileOptions {
emit_ir_only: true,
..Default::default()
},
)
.expect("nested declared Map.get compiles"),
)
.expect("LLVM IR is UTF-8")
}

fn method_ir<'a>(ir: &'a str, owner: &str, method: &str) -> &'a str {
let suffix = format!("__{owner}__{method}(");
let suffix_start = ir.find(&suffix).expect("requested method is present");
Expand Down Expand Up @@ -280,3 +381,18 @@ fn imported_class_readonly_set_field_uses_branded_fast_path() {
"cross-module field metadata must not force native Sets through generic dispatch:\n{method_ir}"
);
}

#[test]
fn nested_interface_map_field_get_uses_branded_dispatch() {
let ir = compile_nested_map_get_ir();
let method_ir = method_ir(&ir, "CommandExecutor", "lookup");

assert!(
method_ir.contains("call double @js_declared_map_get("),
"a Map reached through a nested interface field must retain a branded dispatch candidate:\n{method_ir}"
);
assert!(
!method_ir.contains("call double @js_native_call_method_by_id("),
"a genuine native Map must not enter generic method dispatch at this site:\n{method_ir}"
);
}
20 changes: 18 additions & 2 deletions crates/perry-codegen/src/lower_call/property_get/map_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::expr::{lower_expr, unbox_to_i64, FnCtx};
use crate::nanbox::double_literal;
use crate::rooting;
use crate::type_analysis::{
is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr,
is_declared_map_expr, is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr,
};
use crate::types::{DOUBLE, I64};

Expand Down Expand Up @@ -59,7 +59,23 @@ pub(crate) fn try_lower_map_set_methods(
)))
});
}
if is_map_expr(ctx, object) {
let is_native_map = is_map_expr(ctx, object);
// A nested interface/object field can retain its `Map` or `ReadonlyMap`
// declaration after the stronger native-layout proof is lost. Avoid the
// full property/method dispatcher for a genuine native Map, but preserve
// structural and subclass behavior through the runtime brand miss.
if !is_native_map && is_declared_map_expr(ctx, object) && property == "get" && args.len() == 1 {
return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| {
let receiver = vals[0].clone();
let key = vals[1].clone();
Ok(Some(ctx.block().call(
DOUBLE,
"js_declared_map_get",
&[(DOUBLE, &receiver), (DOUBLE, &key)],
)))
});
}
if is_native_map {
match property {
"set" if args.len() == 2 => {
// #6970: each finished operand is live in an SSA register
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
module.declare_function("js_map_set_string_string", I64, &[I64, I64, I64]);
module.declare_function("js_map_set_number_key", I64, &[I64, DOUBLE, DOUBLE]);
module.declare_function("js_map_get", DOUBLE, &[I64, DOUBLE]);
module.declare_function("js_declared_map_get", DOUBLE, &[DOUBLE, DOUBLE]);
module.declare_function("js_map_get_string_key", DOUBLE, &[I64, I64]);
module.declare_function("js_map_get_number_key", DOUBLE, &[I64, DOUBLE]);
module.declare_function("js_map_has", I32, &[I64, DOUBLE]);
Expand Down
8 changes: 4 additions & 4 deletions crates/perry-codegen/src/type_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ pub(crate) use refine::{
proven_type_from_init, refine_type_from_init,
};
pub(crate) use strings::{
class_name_extends_url_search_params, is_declared_string_expr, is_definitely_string_expr,
is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr, is_url_search_params_expr,
is_url_search_params_subclass_expr, map_static_type_args, set_static_type_args,
string_proof_is_declared_only, string_value_is_runtime_guaranteed,
class_name_extends_url_search_params, is_declared_map_expr, is_declared_string_expr,
is_definitely_string_expr, is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr,
is_url_search_params_expr, is_url_search_params_subclass_expr, map_static_type_args,
set_static_type_args, string_proof_is_declared_only, string_value_is_runtime_guaranteed,
};

#[cfg(test)]
Expand Down
64 changes: 57 additions & 7 deletions crates/perry-codegen/src/type_analysis/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,33 @@ pub(crate) fn is_readonly_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
}
}

/// True when a declared type says that the expression is a `Map<K, V>` or
/// `ReadonlyMap<K, V>`, but does not by itself prove Perry's native Map
/// layout.
///
/// In particular this retains a useful candidate through nested structural
/// fields such as `this.ctx.entityToArchetype`. Callers must use a branded
/// runtime operation with ordinary method dispatch on a brand miss.
pub(crate) fn is_declared_map_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
match e {
Expr::LocalGet(id) => ctx.local_type_hint(id).is_some_and(type_is_declared_map),
Expr::PropertyGet {
object, property, ..
} => static_type_of(ctx, object).is_some_and(|owner_ty| {
type_may_declare_collection_field(ctx, &owner_ty, property, type_is_declared_map, 0)
}),
_ => false,
}
}

#[inline]
fn type_is_declared_map(ty: &HirType) -> bool {
matches!(
ty,
HirType::Generic { base, .. } if base == "Map" || base == "ReadonlyMap"
)
}

#[inline]
fn type_is_readonly_set(ty: &HirType) -> bool {
matches!(ty, HirType::Generic { base, .. } if base == "ReadonlySet")
Expand All @@ -76,35 +103,58 @@ fn type_may_declare_readonly_set_field(
owner_ty: &HirType,
property: &str,
depth: usize,
) -> bool {
type_may_declare_collection_field(ctx, owner_ty, property, type_is_readonly_set, depth)
}

fn type_may_declare_collection_field(
ctx: &FnCtx<'_>,
owner_ty: &HirType,
property: &str,
matches_collection: fn(&HirType) -> bool,
depth: usize,
) -> bool {
if depth > 32 {
return false;
}
match owner_ty {
HirType::Union(variants) => variants.iter().any(|variant| {
!matches!(variant, HirType::Null | HirType::Void | HirType::Never)
&& type_may_declare_readonly_set_field(ctx, variant, property, depth + 1)
&& type_may_declare_collection_field(
ctx,
variant,
property,
matches_collection,
depth + 1,
)
}),
HirType::Named(name) | HirType::Generic { base: name, .. } => {
if let Some(class) = ctx.classes.get(name) {
if let Some(field) = class.fields.iter().find(|field| field.name == property) {
return type_is_readonly_set(&field.ty);
return matches_collection(&field.ty);
}
if let Some(parent) = class.extends_name.as_deref() {
return type_may_declare_readonly_set_field(
return type_may_declare_collection_field(
ctx,
&HirType::Named(parent.to_string()),
property,
matches_collection,
depth + 1,
);
}
}
if let Some(iface) = ctx.interfaces.get(name) {
if let Some(field) = iface.properties.iter().find(|field| field.name == property) {
return type_is_readonly_set(&field.ty);
return matches_collection(&field.ty);
}
if iface.extends.iter().any(|parent| {
type_may_declare_readonly_set_field(ctx, parent, property, depth + 1)
type_may_declare_collection_field(
ctx,
parent,
property,
matches_collection,
depth + 1,
)
}) {
return true;
}
Expand All @@ -115,13 +165,13 @@ fn type_may_declare_readonly_set_field(
if object
.properties
.get(property)
.is_some_and(|field| type_is_readonly_set(&field.ty))
.is_some_and(|field| matches_collection(&field.ty))
)
}
HirType::Object(object) => object
.properties
.get(property)
.is_some_and(|field| type_is_readonly_set(&field.ty)),
.is_some_and(|field| matches_collection(&field.ty)),
_ => false,
}
}
Expand Down
39 changes: 39 additions & 0 deletions crates/perry-runtime/src/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,11 @@ pub extern "C" fn js_map_get(map: *const MapHeader, key: f64) -> f64 {
if map.is_null() {
return f64::from_bits(TAG_UNDEFINED);
}
map_get_resolved(map, key)
}

#[inline(always)]
fn map_get_resolved(map: *const MapHeader, key: f64) -> f64 {
let key = normalize_zero(key);
unsafe {
let idx = find_key_index(map, key);
Expand All @@ -1650,6 +1655,40 @@ pub extern "C" fn js_map_get(map: *const MapHeader, key: f64) -> f64 {
}
}

/// Fast `Map.get`/`ReadonlyMap.get` for a declared structural receiver.
///
/// A TypeScript collection annotation does not prove Perry's native layout.
/// Genuine `GC_TYPE_MAP` receivers bypass generic property/method dispatch;
/// structural objects, proxies, subclasses, primitives, and nullish values
/// retain ordinary `receiver.get(key)` behavior on a brand miss.
#[no_mangle]
pub unsafe extern "C-unwind" fn js_declared_map_get(receiver: f64, key: f64) -> f64 {
let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits());
if receiver_value.is_pointer() {
let raw = receiver_value.as_pointer::<MapHeader>();
if matches!(
crate::value::addr_class::try_read_gc_header(raw as usize),
Some(header) if header.obj_type == crate::gc::GC_TYPE_MAP
) {
return map_get_resolved(raw, key);
}
}

// Generic dispatch can allocate and re-enter generated code. Keep both
// operands rooted and refresh them before crossing that boundary.
let scope = crate::gc::RuntimeHandleScope::new();
let receiver_handle = scope.root_nanbox_f64(receiver);
let key_handle = scope.root_nanbox_f64(key);
let refreshed_key = key_handle.get_nanbox_f64();
crate::object::js_native_call_method(
receiver_handle.get_nanbox_f64(),
b"get".as_ptr() as *const i8,
3,
&refreshed_key,
1,
)
}

#[no_mangle]
pub extern "C" fn js_map_get_number_key(map: *const MapHeader, key: f64) -> f64 {
let Some(key) = normalize_number_key_from_boxed(key) else {
Expand Down
Loading