Skip to content

Commit

Permalink
Eval a fast property memoizer to microoptimize property access for ca…
Browse files Browse the repository at this point in the history
…ched views

This implements a better performing getter function for readonly instances. Before this, we used the same definition of the getter function for all views, which ended up megamorphic, because it accessed a very wide variety of properties for every instance! Instead, this evals a monomorphic getter for each one. I also changed the object that stores the memos to have a fixed shape from birth by evaling it out as well.

I also changed us to use one object to store all the memoized values, instead of two. We were previously using two in order to implement memoization of undefined and null correctly, but with the new strategy to initialize an object with slots for every memo from the start, we can populate it with a `$notYetMemoized` symbol that indicates if we have memoized or not.
  • Loading branch information
airhorns committed May 31, 2024
1 parent 3447b04 commit e1d74e8
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 30 deletions.
28 changes: 10 additions & 18 deletions src/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import type { IModelType as MSTIModelType, ModelActions } from "mobx-state-tree"
import { types as mstTypes } from "mobx-state-tree";
import { RegistrationError } from "./errors";
import { buildFastInstantiator } from "./fast-instantiator";
import { FastGetBuilder } from "./fast-getter";
import { defaultThrowAction, mstPropsFromQuickProps, propsFromModelPropsDeclaration } from "./model";
import {
$context,
$identifier,
$memoizedKeys,
$memos,
$originalDescriptor,
$parent,
Expand Down Expand Up @@ -40,7 +40,7 @@ type ActionMetadata = {
};

/** @internal */
type ViewMetadata = {
export type ViewMetadata = {
type: "view";
property: string;
};
Expand All @@ -53,7 +53,8 @@ export type VolatileMetadata = {
};

type VolatileInitializer<T> = (instance: T) => Record<string, any>;
type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata;
/** @internal */
export type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata;

const metadataPrefix = "mqt:properties";
const viewKeyPrefix = `${metadataPrefix}:view`;
Expand Down Expand Up @@ -91,9 +92,7 @@ class BaseClassModel {
/** @hidden */
[$identifier]?: any;
/** @hidden */
[$memos]!: Record<string, any> | null;
/** @hidden */
[$memoizedKeys]!: Record<string, boolean> | null;
[$memos]!: Record<string, boolean> | null;
}

/**
Expand Down Expand Up @@ -158,6 +157,8 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
});
}

const fastGetters = new FastGetBuilder(metadatas, klass);

for (const metadata of metadatas) {
switch (metadata.type) {
case "view": {
Expand All @@ -171,16 +172,7 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
if (descriptor.get) {
Object.defineProperty(klass.prototype, property, {
...descriptor,
get() {
if (!this[$readOnly]) return descriptor.get!.call(this);
if (this[$memoizedKeys]?.[property]) return this[$memos][property];
this[$memoizedKeys] ??= {};
this[$memos] ??= {};
const value: any = descriptor.get!.call(this);
this[$memoizedKeys][property] = true;
this[$memos][property] = value;
return value;
},
get: fastGetters.buildGetter(property, descriptor),
});
}

Expand Down Expand Up @@ -278,7 +270,7 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
// - .createReadOnly
// - .is
// - .instantiate
return buildFastInstantiator(klass) as any;
return buildFastInstantiator(klass, fastGetters) as any;
}

/**
Expand Down Expand Up @@ -449,7 +441,7 @@ function allPrototypeFunctionProperties(obj: any): string[] {
* Get the property descriptor for a property from anywhere in the prototype chain
* Similar to Object.getOwnPropertyDescriptor, but without the own bit
*/
function getPropertyDescriptor(obj: any, property: string) {
export function getPropertyDescriptor(obj: any, property: string) {
while (obj) {
const descriptor = Object.getOwnPropertyDescriptor(obj, property);
if (descriptor) {
Expand Down
57 changes: 57 additions & 0 deletions src/fast-getter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { PropertyMetadata, ViewMetadata } from "./class-model";
import { getPropertyDescriptor } from "./class-model";
import { RegistrationError } from "./errors";
import { $memos, $notYetMemoized, $readOnly } from "./symbols";

/** Assemble a function for getting the value of a readonly instance very quickly with static dispatch to properties */

export class FastGetBuilder {
memoizableProperties: string[];

constructor(
metadatas: PropertyMetadata[],
readonly klass: { new (...args: any[]): any },
) {
this.memoizableProperties = metadatas
.filter((metadata): metadata is ViewMetadata => {
if (metadata.type !== "view") return false;
const property = metadata.property;
const descriptor = getPropertyDescriptor(klass.prototype, property);
if (!descriptor) {
throw new RegistrationError(`Property ${property} not found on ${klass} prototype, can't register view for class model`);
}
return descriptor.get !== undefined;
})
.map((metadata) => metadata.property);
}

constructorStatements() {
return `
this[$memos] = {${this.memoizableProperties.map((property) => `${property}: $notYetMemoized`).join(",")}};
`;
}

buildGetter(property: string, descriptor: PropertyDescriptor) {
const builder = eval(`
(
function build({ $readOnly, $memos, $notYetMemoized, getValue }) {
return function get${property}(model, imports) {
if (!this[$readOnly]) return getValue.call(this);
let value = this[$memos].${property};
if (value != $notYetMemoized) {
return value;
}
value = getValue.call(this);
this[$memos].${property} = value;
return value;
}
}
)
//# sourceURL=mqt-eval/dynamic/${this.klass.name}-${property}-get.js
`);

return builder({ $readOnly, $memos, $notYetMemoized, getValue: descriptor.get });
}
}
24 changes: 15 additions & 9 deletions src/fast-instantiator.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { ArrayType, QuickArray } from "./array";
import type { FastGetBuilder } from "./fast-getter";
import { FrozenType } from "./frozen";
import { MapType, QuickMap } from "./map";
import { OptionalType } from "./optional";
import { ReferenceType, SafeReferenceType } from "./reference";
import { DateType, IntegerType, LiteralType, SimpleType } from "./simple";
import { $context, $identifier, $memoizedKeys, $memos, $parent, $readOnly, $type } from "./symbols";
import { $context, $identifier, $memos, $notYetMemoized, $parent, $readOnly, $type } from "./symbols";
import type { IAnyType, IClassModelType, ValidOptionalValue } from "./types";

/**
* Compiles a fast function for taking snapshots and turning them into instances of a class model.
**/
export const buildFastInstantiator = <T extends IClassModelType<Record<string, IAnyType>, any, any>>(model: T): T => {
return new InstantiatorBuilder(model).build();
export const buildFastInstantiator = <T extends IClassModelType<Record<string, IAnyType>, any, any>>(
model: T,
fastGetters: FastGetBuilder,
): T => {
return new InstantiatorBuilder(model, fastGetters).build();
};

type DirectlyAssignableType = SimpleType<any> | IntegerType | LiteralType<any> | DateType;
Expand All @@ -28,7 +32,10 @@ const isDirectlyAssignableType = (type: IAnyType): type is DirectlyAssignableTyp
class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyType>, any, any>> {
aliases = new Map<string, string>();

constructor(readonly model: T) {}
constructor(
readonly model: T,
readonly getters: FastGetBuilder,
) {}

build(): T {
const segments: string[] = [];
Expand Down Expand Up @@ -74,16 +81,15 @@ class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyType>, an
`);
}

segments.push(this.getters.constructorStatements());

let className = this.model.name;
if (!className || className.trim().length == 0) {
className = "AnonymousModel";
}

const defineClassStatement = `
return class ${className} extends model {
[$memos] = null;
[$memoizedKeys] = null;
static createReadOnly = (snapshot, env) => {
const context = {
referenceCache: new Map(),
Expand Down Expand Up @@ -137,7 +143,7 @@ class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyType>, an
`;

const aliasFuncBody = `
const { QuickMap, QuickArray, $identifier, $context, $parent, $memos, $memoizedKeys, $readOnly, $type } = imports;
const { QuickMap, QuickArray, $identifier, $context, $parent, $memos, $notYetMemoized, $readOnly, $type } = imports;
${Array.from(this.aliases.entries())
.map(([expression, alias]) => `const ${alias} = ${expression};`)
Expand Down Expand Up @@ -167,9 +173,9 @@ class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyType>, an
$context,
$parent,
$memos,
$memoizedKeys,
$readOnly,
$type,
$notYetMemoized,
QuickMap,
QuickArray,
}) as T;
Expand Down
6 changes: 3 additions & 3 deletions src/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ export const $registered = Symbol.for("MQT_registered");
export const $volatileDefiner = Symbol.for("MQT_volatileDefiner");

/**
* The values of memoized properties on an MQT instance
* The list of properties which have been memoized
* @hidden
**/
export const $memos = Symbol.for("mqt:class-model-memos");

/**
* The list of properties which have been memoized
* The value we use in the memos map when we haven't populated the memo yet
* @hidden
**/
export const $memoizedKeys = Symbol.for("mqt:class-model-memoized-keys");
export const $notYetMemoized = Symbol.for("mqt:not-yet-memoized");

0 comments on commit e1d74e8

Please sign in to comment.