diff --git a/.eslintrc b/.eslintrc index 2535c092..127578da 100644 --- a/.eslintrc +++ b/.eslintrc @@ -113,7 +113,8 @@ "time", "timeEnd", "group", - "groupEnd" + "groupEnd", + "table" ] } ], diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 00000000..62a9fde0 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,30 @@ +name: Run Benchmarks + +on: [pull_request] + +jobs: + run-benchmarks: + if: ${{ contains(github.event.pull_request.body, '[Run benchmarks]') }} + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Read .nvmrc + run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" + id: nvm + - name: Use Node.js (.nvmrc) + uses: actions/setup-node@v3 + with: + node-version: "${{ steps.nvm.outputs.NVMRC }}" + - name: Install dependencies + run: npm install + + - name: Run benchmarks + run: npm run test:benchmarks | tail -n +7 > bench-results.txt + continue-on-error: true + + - name: Post results to comment + uses: peter-evans/commit-comment@v3 + with: + body-path: 'bench-results.txt' diff --git a/package.json b/package.json index 6b956bec..b742fd42 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wollok-ts", "version": "4.1.10", - "wollokVersion": ":master", + "wollokVersion": ":optimization-wollok-game", "description": "TypeScript based Wollok language implementation", "repository": "https://github.com/uqbar-project/wollok-ts", "license": "MIT", @@ -15,7 +15,7 @@ "prepare": "ts-node scripts/fetchLanguage.ts && npm run buildWRE && npm run copy:translations", "copy:translations": "cp ./language/src/resources/validationMessages/*.json ./src/validator", "diagnostic": "tsc --noEmit --diagnostics --extendedDiagnostics", - "test": "npm run test:lint && npm run test:unit && npm run test:sanity && npm run test:examples && npm run test:validations && npm run test:typeSystem && npm run test:printer", + "test": "npm run test:lint && npm run test:unit && npm run test:sanity && npm run test:examples", "test:lint": "eslint .", "test:coverage": "nyc --reporter=lcov npm run test", "test:unit": "mocha --parallel -r ts-node/register/transpile-only test/**/*.test.ts", @@ -33,6 +33,7 @@ "test:wtest": "mocha --delay -t 10000 -r ts-node/register/transpile-only test/wtest.ts", "test:printer": "mocha --parallel -r ts-node/register/transpile-only test/printer.test.ts", "test:parser": "mocha -r ts-node/register/transpile-only test/parser.test.ts", + "test:benchmarks": "mocha -t 999999 -r ts-node/register/transpile-only test/benchmarks.ts", "lint:fix": "eslint . --fix", "validate:wollokVersion": "ts-node scripts/validateWollokVersion.ts", "prepublishOnly": "npm run validate:wollokVersion && npm run build && npm test", diff --git a/src/helpers.ts b/src/helpers.ts index a185e4b6..e6e3a442 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,7 +1,7 @@ import { BOOLEAN_MODULE, CLOSURE_EVALUATE_METHOD, CLOSURE_MODULE, CLOSURE_TO_STRING_METHOD, INITIALIZE_METHOD, KEYWORDS, NUMBER_MODULE, OBJECT_MODULE, STRING_MODULE, VOID_WKO, WOLLOK_BASE_PACKAGE } from './constants' import { getPotentiallyUninitializedLazy } from './decorators' import { count, is, isEmpty, last, List, match, notEmpty, otherwise, valueAsListOrEmpty, when, excludeNullish } from './extensions' -import { RuntimeObject, RuntimeValue } from './interpreter/runtimeModel' +import { Execution, NativeFunction, RuntimeObject, RuntimeValue } from './interpreter/runtimeModel' import { Assignment, Body, Class, CodeContainer, Describe, Entity, Environment, Expression, Field, If, Import, Literal, LiteralValue, Method, Module, Name, NamedArgument, New, Node, Package, Parameter, ParameterizedType, Problem, Program, Reference, Referenciable, Return, Self, Send, Sentence, Singleton, Super, Test, Throw, Try, Variable } from './model' export const LIBRARY_PACKAGES = ['wollok.lang', 'wollok.lib', 'wollok.game', 'wollok.vm', 'wollok.mirror'] @@ -477,4 +477,21 @@ export const showParameter = (obj: RuntimeObject): string => `"${obj.getShortRepresentation().trim() || obj.module.fullyQualifiedName}"` export const getMethodContainer = (node: Node): Method | Program | Test | undefined => - last(node.ancestors.filter(parent => parent.is(Method) || parent.is(Program) || parent.is(Test))) as unknown as Method | Program | Test \ No newline at end of file + last(node.ancestors.filter(parent => parent.is(Method) || parent.is(Program) || parent.is(Test))) as unknown as Method | Program | Test + +/** + * NATIVES + */ +export const compilePropertyMethod = (method: Method): NativeFunction => { + const message = method.name + return method.parameters.length == 0 + ? compileGetter(message) + : compileSetter(message) +} + +export const compileGetter = (message: string): NativeFunction => function* (self: RuntimeObject): Execution { + return self.get(message) +} +export const compileSetter = (message: string): NativeFunction => function* (self: RuntimeObject, value: RuntimeObject): Execution { + self.set(message, value) +} \ No newline at end of file diff --git a/src/interpreter/runtimeModel.ts b/src/interpreter/runtimeModel.ts index 48f1f3df..f6bdec28 100644 --- a/src/interpreter/runtimeModel.ts +++ b/src/interpreter/runtimeModel.ts @@ -1,7 +1,7 @@ import { v4 as uuid } from 'uuid' import { BOOLEAN_MODULE, CLOSURE_EVALUATE_METHOD, CLOSURE_MODULE, DATE_MODULE, DICTIONARY_MODULE, EXCEPTION_MODULE, INITIALIZE_METHOD, KEYWORDS, LIST_MODULE, NUMBER_MODULE, OBJECT_MODULE, PAIR_MODULE, RANGE_MODULE, SET_MODULE, STRING_MODULE, TO_STRING_METHOD, VOID_WKO, WOLLOK_BASE_PACKAGE, WOLLOK_EXTRA_STACK_TRACE_HEADER } from '../constants' import { get, is, last, List, match, otherwise, raise, when } from '../extensions' -import { assertNotVoid, getExpressionFor, getMethodContainer, getUninitializedAttributesForInstantiation, isNamedSingleton, isVoid, loopInAssignment, showParameter, superMethodDefinition, targetName } from '../helpers' +import { assertNotVoid, compilePropertyMethod, getExpressionFor, getMethodContainer, getUninitializedAttributesForInstantiation, isNamedSingleton, isVoid, loopInAssignment, showParameter, superMethodDefinition, targetName } from '../helpers' import { Assignment, Body, Catch, Class, Describe, Entity, Environment, Expression, Field, Id, If, Literal, LiteralValue, Method, Module, Name, New, Node, Program, Reference, Return, Self, Send, Singleton, Super, Test, Throw, Try, Variable } from '../model' import { Interpreter } from './interpreter' @@ -317,11 +317,15 @@ export class Evaluation { // Set natives environment.forEach(node => { - if (node.is(Method) && node.isNative()) - evaluation.natives.set(node, get(natives, `${node.parent.fullyQualifiedName}.${node.name}`)!) + if (node.is(Method)) + if (node.isNative()) + evaluation.natives.set(node, get(natives, `${node.parent.fullyQualifiedName}.${node.name}`)!) + else if (node.fromProperty) { + evaluation.natives.set(node, compilePropertyMethod(node)) + node.compiled = true + } }) - // Instanciate globals const globalSingletons = environment.descendants.filter((node: Node): node is Singleton => isNamedSingleton(node)) for (const module of globalSingletons) @@ -459,7 +463,7 @@ export class Evaluation { protected *execMethod(node: Method): Execution { yield node - if (node.isNative()) { + if (node.hasNativeImplementation) { const native = this.natives.get(node) if (!native) throw new Error(`Missing native for ${node.parent.fullyQualifiedName}.${node.name}`) diff --git a/src/model.ts b/src/model.ts index 3b591403..808b6dd6 100644 --- a/src/model.ts +++ b/src/model.ts @@ -659,6 +659,8 @@ export class Method extends Node { override parent!: Module + compiled = false + constructor({ isOverride = false, parameters = [], ...payload }: Payload) { super({ isOverride, parameters, ...payload }) } @@ -672,8 +674,13 @@ export class Method extends Node { } isAbstract(): this is { body: undefined } { return !this.body } - isNative(): this is { body?: Body } { return this.body === KEYWORDS.NATIVE } isConcrete(): this is { body: Body } { return !this.isAbstract() && !this.isNative() } + isNative(): this is { body?: Body } { return this.body === KEYWORDS.NATIVE } + + get hasNativeImplementation(): boolean { return this.isNative() || this.compiled } + + @cached + get fromProperty(): boolean { return this.isSynthetic && this.parameters.length < 2 && !!this.parent.lookupField(this.name) } @cached get hasVarArgs(): boolean { return !!last(this.parameters)?.isVarArg } diff --git a/src/wre/game.ts b/src/wre/game.ts index 8a4c3c9d..55a0633a 100644 --- a/src/wre/game.ts +++ b/src/wre/game.ts @@ -1,7 +1,44 @@ import { GAME_MODULE } from '../constants' -import { assertIsNumber, assertIsNotNull, Execution, Natives, RuntimeObject, RuntimeValue } from '../interpreter/runtimeModel' +import { assertIsNotNull, assertIsNumber, Evaluation, Execution, NativeFunction, Natives, RuntimeObject, RuntimeValue } from '../interpreter/runtimeModel' const { round } = Math + +/** + * Avoid to invoke getters method from properties by accessing directly to the variable + */ +const getter = (message: string): NativeFunction => function* (obj: RuntimeObject): Execution { + const method = obj.module.lookupMethod(message, 0)! + return method.isSynthetic ? obj.get(message)! : yield* this.invoke(method, obj) +} + +const getPosition = getter('position') +const getX = getter('x') +const getY = getter('y') + +const getObjectsIn = function* (this: Evaluation, position: RuntimeObject, ...visuals: RuntimeObject[]): Execution { + const result: RuntimeObject[] = [] + + const x = (yield* getX.call(this, position))?.innerNumber + const y = (yield* getY.call(this, position))?.innerNumber + + if (x == undefined || y == undefined) throw new RangeError('Position without coordinates') + + const roundedX = round(x) + const roundedY = round(y) + for (const visual of visuals) { + const otherPosition = (yield* getPosition.call(this, visual))! + const otherX = (yield* getX.call(this, otherPosition))?.innerNumber + const otherY = (yield* getY.call(this, otherPosition))?.innerNumber + + if (otherX == undefined || otherY == undefined) continue // Do NOT throw exception + + if (roundedX == round(otherX) && roundedY == round(otherY)) + result.push(visual) + } + + return yield* this.list(...result) +} + const game: Natives = { game: { *addVisual(self: RuntimeObject, positionable: RuntimeObject): Execution { @@ -30,33 +67,7 @@ const game: Natives = { *getObjectsIn(self: RuntimeObject, position: RuntimeObject): Execution { const visuals = self.get('visuals')! - const result: RuntimeObject[] = [] - const x = position.get('x')?.innerNumber - const y = position.get('y')?.innerNumber - - - if(x != undefined && y != undefined) { - const roundedX = round(x) - const roundedY = round(y) - for(const visual of visuals.innerCollection!) { - - // Every visual understand position(), it is checked in addVisual(visual). - // Avoid to invoke method position() for optimisation reasons. - // -> If method isSynthetic then it is a getter, we can access to the field directly - const method = visual.module.lookupMethod('position', 0)! - const otherPosition = method.isSynthetic ? visual.get('position') :yield* this.invoke(method, visual) - - const otherX = otherPosition?.get('x')?.innerNumber - const otherY = otherPosition?.get('y')?.innerNumber - - if(otherX == undefined || otherY == undefined) continue - - if(roundedX == round(otherX) && roundedY == round(otherY)) - result.push(visual) - } - } - - return yield* this.list(...result) + return yield* getObjectsIn.call(this, position, ...visuals.innerCollection!) }, *say(self: RuntimeObject, visual: RuntimeObject, message: RuntimeObject): Execution { @@ -71,26 +82,25 @@ const game: Natives = { *colliders(self: RuntimeObject, visual: RuntimeObject): Execution { assertIsNotNull(visual, 'colliders', 'visual') - const position = (yield* this.send('position', visual))! - const visualsAtPosition: RuntimeObject = (yield* this.send('getObjectsIn', self, position))! - - yield* this.send('remove', visualsAtPosition, visual) + const visuals = self.get('visuals')! + const otherVisuals = visuals.innerCollection!.filter(obj => obj != visual) + const position = (yield* getPosition.call(this, visual))! - return visualsAtPosition + return yield* getObjectsIn.call(this, position, ...otherVisuals) }, *title(self: RuntimeObject, title?: RuntimeObject): Execution { - if(!title) return self.get('title') + if (!title) return self.get('title') self.set('title', title) }, *width(self: RuntimeObject, width?: RuntimeObject): Execution { - if(!width) return self.get('width') + if (!width) return self.get('width') self.set('width', width) }, *height(self: RuntimeObject, height?: RuntimeObject): Execution { - if(!height) return self.get('height') + if (!height) return self.get('height') self.set('height', height) }, @@ -135,9 +145,9 @@ const game: Natives = { const game = this.object(GAME_MODULE)! const sounds = game.get('sounds') - if(sounds) yield* this.send('remove', sounds, self) + if (sounds) yield* this.send('remove', sounds, self) - self.set('status', yield * this.reify('stopped')) + self.set('status', yield* this.reify('stopped')) }, *pause(self: RuntimeObject): Execution { @@ -161,7 +171,7 @@ const game: Natives = { }, *volume(self: RuntimeObject, newVolume?: RuntimeObject): Execution { - if(!newVolume) return self.get('volume') + if (!newVolume) return self.get('volume') const volume: RuntimeObject = newVolume assertIsNumber(volume, 'volume', 'newVolume', false) @@ -172,7 +182,7 @@ const game: Natives = { }, *shouldLoop(self: RuntimeObject, looping?: RuntimeObject): Execution { - if(!looping) return self.get('loop') + if (!looping) return self.get('loop') self.set('loop', looping) }, diff --git a/test/benchmarks.ts b/test/benchmarks.ts new file mode 100644 index 00000000..ee5c4035 --- /dev/null +++ b/test/benchmarks.ts @@ -0,0 +1,68 @@ +import { should } from 'chai' +import { resolve } from 'path' +import { restore, stub } from 'sinon' +import { PROGRAM_FILE_EXTENSION } from '../src' +import { interpret } from '../src/interpreter/interpreter' +import natives from '../src/wre/wre.natives' +import { buildEnvironment } from './assertions' + +should() + +describe('Benchmarks', () => { + const results: any[] = [] + + after(() => console.table(results)) + + describe('flushEvents', () => { + + function benchmark(fqn: string, expectedTime = 0) { + it(fqn, async () => { + stub(console) + const iterations = 30 + + const program = `games.${fqn}` + const message = 'flushEvents' + + let totalTime = 0 + for (let index = 0; index < iterations; index++) + totalTime += await measure(program, message) + + + const time = totalTime / iterations + const deltaError = expectedTime * 0.15 // 15 % + restore() + + // console.info(`${message} - ${fqn} - ${time} ms (${iterations} iterations)`) + results.push({ message, fqn, time, iterations }) + time.should.be.closeTo(expectedTime, deltaError) + }) + } + + benchmark('empty', 6) + benchmark('visuals_1', 4.5) + benchmark('visuals_100', 4) + benchmark('ticks_1', 12) + benchmark('ticks_100', 637) + benchmark('onCollide_1', 11) + benchmark('onCollide_10_same_position', 5000) + benchmark('onCollide_100_diff_positions', 675) + + }) +}) + +async function measure(programFQN: string, message: string): Promise { + const environment = await buildEnvironment(`**/*.${PROGRAM_FILE_EXTENSION}`, resolve('language', 'benchmarks')) + const interpreter = interpret(environment, natives) + + interpreter.run(programFQN) + const game = interpreter.object('wollok.game.game') + + interpreter.send(message, game, interpreter.reify(0)) // Fill caches + const startTime = performance.now() + for (let ms = 1; ms < 10; ms++) + interpreter.send(message, game, interpreter.reify(ms)) + const endTime = performance.now() + + const elapsedTime = endTime - startTime + return elapsedTime +} \ No newline at end of file