Added multi-os runners for benchmark & implemented luau analyze (#542)

This commit is contained in:
Allan N Jeremy 2022-06-24 19:46:29 +03:00 committed by GitHub
parent e91d80ee25
commit 5e405b58b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 1217 additions and 14 deletions

View File

@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- master - master
paths-ignore: paths-ignore:
- "docs/**" - "docs/**"
- "papers/**" - "papers/**"
@ -13,12 +12,13 @@ on:
- "prototyping/**" - "prototyping/**"
jobs: jobs:
benchmarks-run: windows:
name: Run ${{ matrix.bench.title }} name: Run ${{ matrix.bench.title }} (Windows ${{matrix.arch}})
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest] os: [windows-latest]
arch: [Win32, x64]
bench: bench:
- { - {
script: "run-benchmarks", script: "run-benchmarks",
@ -32,7 +32,93 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout Luau - name: Checkout Luau repository
uses: actions/checkout@v3
- name: Build Luau
shell: bash # necessary for fail-fast
run: |
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . --target Luau.Repl.CLI --config Release
cmake --build . --target Luau.Analyze.CLI --config Release
- name: Move build files to root
run: |
move build/RelWithDebInfo/* .
- name: Check dir structure
run: |
ls build/RelWithDebInfo
ls
- uses: actions/setup-python@v3
with:
python-version: "3.9"
architecture: "x64"
- name: Install python dependencies
run: |
python -m pip install requests
python -m pip install --user numpy scipy matplotlib ipython jupyter pandas sympy nose
- name: Run benchmark
run: |
python bench/bench.py | tee ${{ matrix.bench.script }}-output.txt
- name: Checkout Benchmark Results repository
uses: actions/checkout@v3
with:
repository: ${{ matrix.benchResultsRepo.name }}
ref: ${{ matrix.benchResultsRepo.branch }}
token: ${{ secrets.BENCH_GITHUB_TOKEN }}
path: "./gh-pages"
- name: Store ${{ matrix.bench.title }} result
uses: Roblox/rhysd-github-action-benchmark@v-luau
with:
name: ${{ matrix.bench.title }} (Windows ${{matrix.arch}})
tool: "benchmarkluau"
output-file-path: ./${{ matrix.bench.script }}-output.txt
external-data-json-path: ./gh-pages/dev/bench/data.json
alert-threshold: 150%
fail-threshold: 200%
fail-on-alert: true
comment-on-alert: true
comment-always: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Push benchmark results
if: github.event_name == 'push'
run: |
echo "Pushing benchmark results..."
cd gh-pages
git config user.name github-actions
git config user.email github@users.noreply.github.com
git add ./dev/bench/data.json
git commit -m "Add benchmarks results for ${{ github.sha }}"
git push
cd ..
unix:
name: Run ${{ matrix.bench.title }} (${{ matrix.os}})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
bench:
- {
script: "run-benchmarks",
timeout: 12,
title: "Luau Benchmarks",
cachegrindTitle: "Performance",
cachegrindIterCount: 20,
}
benchResultsRepo:
- { name: "luau-lang/benchmark-data", branch: "main" }
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Luau repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build Luau - name: Build Luau
@ -48,18 +134,21 @@ jobs:
python -m pip install requests python -m pip install requests
python -m pip install --user numpy scipy matplotlib ipython jupyter pandas sympy nose python -m pip install --user numpy scipy matplotlib ipython jupyter pandas sympy nose
- name: Install valgrind
run: |
sudo apt-get install valgrind
- name: Run benchmark - name: Run benchmark
run: | run: |
python bench/bench.py | tee ${{ matrix.bench.script }}-output.txt python bench/bench.py | tee ${{ matrix.bench.script }}-output.txt
- name: Install valgrind
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install valgrind
- name: Run ${{ matrix.bench.title }} (Cold Cachegrind) - name: Run ${{ matrix.bench.title }} (Cold Cachegrind)
if: matrix.os == 'ubuntu-latest'
run: sudo bash ./scripts/run-with-cachegrind.sh python ./bench/bench.py "${{ matrix.bench.cachegrindTitle}}Cold" 1 | tee -a ${{ matrix.bench.script }}-output.txt run: sudo bash ./scripts/run-with-cachegrind.sh python ./bench/bench.py "${{ matrix.bench.cachegrindTitle}}Cold" 1 | tee -a ${{ matrix.bench.script }}-output.txt
- name: Run ${{ matrix.bench.title }} (Warm Cachegrind) - name: Run ${{ matrix.bench.title }} (Warm Cachegrind)
if: matrix.os == 'ubuntu-latest'
run: sudo bash ./scripts/run-with-cachegrind.sh python ./bench/bench.py "${{ matrix.bench.cachegrindTitle }}" ${{ matrix.bench.cachegrindIterCount }} | tee -a ${{ matrix.bench.script }}-output.txt run: sudo bash ./scripts/run-with-cachegrind.sh python ./bench/bench.py "${{ matrix.bench.cachegrindTitle }}" ${{ matrix.bench.cachegrindIterCount }} | tee -a ${{ matrix.bench.script }}-output.txt
- name: Checkout Benchmark Results repository - name: Checkout Benchmark Results repository
@ -78,12 +167,14 @@ jobs:
output-file-path: ./${{ matrix.bench.script }}-output.txt output-file-path: ./${{ matrix.bench.script }}-output.txt
external-data-json-path: ./gh-pages/dev/bench/data.json external-data-json-path: ./gh-pages/dev/bench/data.json
alert-threshold: 150% alert-threshold: 150%
fail-threshold: 1000% fail-threshold: 200%
fail-on-alert: false fail-on-alert: true
comment-on-alert: true comment-on-alert: true
comment-always: true
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Store ${{ matrix.bench.title }} result - name: Store ${{ matrix.bench.title }} result (CacheGrind)
if: matrix.os == 'ubuntu-latest'
uses: Roblox/rhysd-github-action-benchmark@v-luau uses: Roblox/rhysd-github-action-benchmark@v-luau
with: with:
name: ${{ matrix.bench.title }} (CacheGrind) name: ${{ matrix.bench.title }} (CacheGrind)
@ -97,7 +188,107 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Push benchmark results - name: Push benchmark results
if: github.event_name == 'push'
run: |
echo "Pushing benchmark results..."
cd gh-pages
git config user.name github-actions
git config user.email github@users.noreply.github.com
git add ./dev/bench/data.json
git commit -m "Add benchmarks results for ${{ github.sha }}"
git push
cd ..
static-analysis:
name: Run ${{ matrix.bench.title }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
engine:
- { channel: stable, version: latest }
bench:
- {
script: "run-analyze",
timeout: 12,
title: "Luau Analyze",
cachegrindTitle: "Performance",
cachegrindIterCount: 20,
}
benchResultsRepo:
- { name: "luau-lang/benchmark-data", branch: "main" }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
with:
token: "${{ secrets.BENCH_GITHUB_TOKEN }}"
- name: Build Luau
run: make config=release luau luau-analyze
- uses: actions/setup-python@v4
with:
python-version: "3.9"
architecture: "x64"
- name: Install python dependencies
run: |
sudo pip install requests numpy scipy matplotlib ipython jupyter pandas sympy nose
- name: Install valgrind
run: |
sudo apt-get install valgrind
- name: Run Luau Analyze on static file
run: sudo python ./bench/measure_time.py ./build/release/luau-analyze bench/static_analysis/LuauPolyfillMap.lua | tee ${{ matrix.bench.script }}-output.txt
- name: Run ${{ matrix.bench.title }} (Cold Cachegrind)
run: sudo ./scripts/run-with-cachegrind.sh python ./bench/measure_time.py "${{ matrix.bench.cachegrindTitle}}Cold" 1 ./build/release/luau-analyze bench/static_analysis/LuauPolyfillMap.lua | tee -a ${{ matrix.bench.script }}-output.txt
- name: Run ${{ matrix.bench.title }} (Warm Cachegrind)
run: sudo bash ./scripts/run-with-cachegrind.sh python ./bench/measure_time.py "${{ matrix.bench.cachegrindTitle}}" 1 ./build/release/luau-analyze bench/static_analysis/LuauPolyfillMap.lua | tee -a ${{ matrix.bench.script }}-output.txt
- name: Checkout Benchmark Results repository
uses: actions/checkout@v3
with:
repository: ${{ matrix.benchResultsRepo.name }}
ref: ${{ matrix.benchResultsRepo.branch }}
token: ${{ secrets.BENCH_GITHUB_TOKEN }}
path: "./gh-pages"
- name: Store ${{ matrix.bench.title }} result
uses: Roblox/rhysd-github-action-benchmark@v-luau
with:
name: ${{ matrix.bench.title }}
tool: "benchmarkluau"
gh-pages-branch: "main"
output-file-path: ./${{ matrix.bench.script }}-output.txt
external-data-json-path: ./gh-pages/dev/bench/data.json
alert-threshold: 150%
fail-threshold: 200%
fail-on-alert: true
comment-on-alert: true
comment-always: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Store ${{ matrix.bench.title }} result (CacheGrind)
uses: Roblox/rhysd-github-action-benchmark@v-luau
with:
name: ${{ matrix.bench.title }}
tool: "roblox"
gh-pages-branch: "main"
output-file-path: ./${{ matrix.bench.script }}-output.txt
external-data-json-path: ./gh-pages/dev/bench/data.json
alert-threshold: 150%
fail-threshold: 200%
fail-on-alert: true
comment-on-alert: true
comment-always: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Push benchmark results
if: github.event_name == 'push'
run: | run: |
echo "Pushing benchmark results..." echo "Pushing benchmark results..."
cd gh-pages cd gh-pages

43
bench/measure_time.py Normal file
View File

@ -0,0 +1,43 @@
# This file is part of the Luau programming language and is licensed under MIT License; see LICENSE.txt for details
import os, sys, time, numpy
try:
import scipy
from scipy import mean, stats
except ModuleNotFoundError:
print("Warning: scipy package is not installed, confidence values will not be available")
stats = None
duration_list = []
DEFAULT_CYCLES_TO_RUN = 100
cycles_to_run = DEFAULT_CYCLES_TO_RUN
try:
cycles_to_run = sys.argv[3] if sys.argv[3] else DEFAULT_CYCLES_TO_RUN
cycles_to_run = int(cycles_to_run)
except IndexError:
pass
except (ValueError, TypeError):
cycles_to_run = DEFAULT_CYCLES_TO_RUN
print("Error: Cycles to run argument must be an integer. Using default value of {}".format(DEFAULT_CYCLES_TO_RUN))
# Numpy complains if we provide a cycle count of less than 3 ~ default to 3 whenever a lower value is provided
cycles_to_run = cycles_to_run if cycles_to_run > 2 else 3
for i in range(1,cycles_to_run):
start = time.perf_counter()
# Run the code you want to measure here
os.system(sys.argv[1])
end = time.perf_counter()
duration_ms = (end - start) * 1000
duration_list.append(duration_ms)
# Stats
mean = numpy.mean(duration_list)
std_err = stats.sem(duration_list)
print("SUCCESS: {} : {:.2f}ms +/- {:.2f}% on luau ".format('duration', mean,std_err))

View File

@ -0,0 +1,962 @@
-- This file is part of the Roblox luau-polyfill repository and is licensed under MIT License; see LICENSE.txt for details
--!nonstrict
-- #region Array
-- Array related
local Array = {}
local Object = {}
local Map = {}
type Array<T> = { [number]: T }
type callbackFn<K, V> = (element: V, key: K, map: Map<K, V>) -> ()
type callbackFnWithThisArg<K, V> = (thisArg: Object, value: V, key: K, map: Map<K, V>) -> ()
type Map<K, V> = {
size: number,
-- method definitions
set: (self: Map<K, V>, K, V) -> Map<K, V>,
get: (self: Map<K, V>, K) -> V | nil,
clear: (self: Map<K, V>) -> (),
delete: (self: Map<K, V>, K) -> boolean,
forEach: (self: Map<K, V>, callback: callbackFn<K, V> | callbackFnWithThisArg<K, V>, thisArg: Object?) -> (),
has: (self: Map<K, V>, K) -> boolean,
keys: (self: Map<K, V>) -> Array<K>,
values: (self: Map<K, V>) -> Array<V>,
entries: (self: Map<K, V>) -> Array<Tuple<K, V>>,
ipairs: (self: Map<K, V>) -> any,
[K]: V,
_map: { [K]: V },
_array: { [number]: K },
}
type mapFn<T, U> = (element: T, index: number) -> U
type mapFnWithThisArg<T, U> = (thisArg: any, element: T, index: number) -> U
type Object = { [string]: any }
type Table<T, V> = { [T]: V }
type Tuple<T, V> = Array<T | V>
local Set = {}
-- #region Array
function Array.isArray(value: any): boolean
if typeof(value) ~= "table" then
return false
end
if next(value) == nil then
-- an empty table is an empty array
return true
end
local length = #value
if length == 0 then
return false
end
local count = 0
local sum = 0
for key in pairs(value) do
if typeof(key) ~= "number" then
return false
end
if key % 1 ~= 0 or key < 1 then
return false
end
count += 1
sum += key
end
return sum == (count * (count + 1) / 2)
end
function Array.from<T, U>(
value: string | Array<T> | Object,
mapFn: (mapFn<T, U> | mapFnWithThisArg<T, U>)?,
thisArg: Object?
): Array<U>
if value == nil then
error("cannot create array from a nil value")
end
local valueType = typeof(value)
local array = {}
if valueType == "table" and Array.isArray(value) then
if mapFn then
for i = 1, #(value :: Array<T>) do
if thisArg ~= nil then
array[i] = (mapFn :: mapFnWithThisArg<T, U>)(thisArg, (value :: Array<T>)[i], i)
else
array[i] = (mapFn :: mapFn<T, U>)((value :: Array<T>)[i], i)
end
end
else
for i = 1, #(value :: Array<T>) do
array[i] = (value :: Array<any>)[i]
end
end
elseif instanceOf(value, Set) then
if mapFn then
for i, v in (value :: any):ipairs() do
if thisArg ~= nil then
array[i] = (mapFn :: mapFnWithThisArg<T, U>)(thisArg, v, i)
else
array[i] = (mapFn :: mapFn<T, U>)(v, i)
end
end
else
for i, v in (value :: any):ipairs() do
array[i] = v
end
end
elseif instanceOf(value, Map) then
if mapFn then
for i, v in (value :: any):ipairs() do
if thisArg ~= nil then
array[i] = (mapFn :: mapFnWithThisArg<T, U>)(thisArg, v, i)
else
array[i] = (mapFn :: mapFn<T, U>)(v, i)
end
end
else
for i, v in (value :: any):ipairs() do
array[i] = v
end
end
elseif valueType == "string" then
if mapFn then
for i = 1, (value :: string):len() do
if thisArg ~= nil then
array[i] = (mapFn :: mapFnWithThisArg<T, U>)(thisArg, (value :: any):sub(i, i), i)
else
array[i] = (mapFn :: mapFn<T, U>)((value :: any):sub(i, i), i)
end
end
else
for i = 1, (value :: string):len() do
array[i] = (value :: any):sub(i, i)
end
end
end
return array
end
type callbackFnArrayMap<T, U> = (element: T, index: number, array: Array<T>) -> U
type callbackFnWithThisArgArrayMap<T, U, V> = (thisArg: V, element: T, index: number, array: Array<T>) -> U
-- Implements Javascript's `Array.prototype.map` as defined below
-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
function Array.map<T, U, V>(
t: Array<T>,
callback: callbackFnArrayMap<T, U> | callbackFnWithThisArgArrayMap<T, U, V>,
thisArg: V?
): Array<U>
if typeof(t) ~= "table" then
error(string.format("Array.map called on %s", typeof(t)))
end
if typeof(callback) ~= "function" then
error("callback is not a function")
end
local len = #t
local A = {}
local k = 1
while k <= len do
local kValue = t[k]
if kValue ~= nil then
local mappedValue
if thisArg ~= nil then
mappedValue = (callback :: callbackFnWithThisArgArrayMap<T, U, V>)(thisArg, kValue, k, t)
else
mappedValue = (callback :: callbackFnArrayMap<T, U>)(kValue, k, t)
end
A[k] = mappedValue
end
k += 1
end
return A
end
type Function = (any, any, number, any) -> any
-- Implements Javascript's `Array.prototype.reduce` as defined below
-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce
function Array.reduce<T>(array: Array<T>, callback: Function, initialValue: any?): any
if typeof(array) ~= "table" then
error(string.format("Array.reduce called on %s", typeof(array)))
end
if typeof(callback) ~= "function" then
error("callback is not a function")
end
local length = #array
local value
local initial = 1
if initialValue ~= nil then
value = initialValue
else
initial = 2
if length == 0 then
error("reduce of empty array with no initial value")
end
value = array[1]
end
for i = initial, length do
value = callback(value, array[i], i, array)
end
return value
end
type callbackFnArrayForEach<T> = (element: T, index: number, array: Array<T>) -> ()
type callbackFnWithThisArgArrayForEach<T, U> = (thisArg: U, element: T, index: number, array: Array<T>) -> ()
-- Implements Javascript's `Array.prototype.forEach` as defined below
-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
function Array.forEach<T, U>(
t: Array<T>,
callback: callbackFnArrayForEach<T> | callbackFnWithThisArgArrayForEach<T, U>,
thisArg: U?
): ()
if typeof(t) ~= "table" then
error(string.format("Array.forEach called on %s", typeof(t)))
end
if typeof(callback) ~= "function" then
error("callback is not a function")
end
local len = #t
local k = 1
while k <= len do
local kValue = t[k]
if thisArg ~= nil then
(callback :: callbackFnWithThisArgArrayForEach<T, U>)(thisArg, kValue, k, t)
else
(callback :: callbackFnArrayForEach<T>)(kValue, k, t)
end
if #t < len then
-- don't iterate on removed items, don't iterate more than original length
len = #t
end
k += 1
end
end
-- #endregion
-- #region Set
Set.__index = Set
type callbackFnSet<T> = (value: T, key: T, set: Set<T>) -> ()
type callbackFnWithThisArgSet<T> = (thisArg: Object, value: T, key: T, set: Set<T>) -> ()
export type Set<T> = {
size: number,
-- method definitions
add: (self: Set<T>, T) -> Set<T>,
clear: (self: Set<T>) -> (),
delete: (self: Set<T>, T) -> boolean,
forEach: (self: Set<T>, callback: callbackFnSet<T> | callbackFnWithThisArgSet<T>, thisArg: Object?) -> (),
has: (self: Set<T>, T) -> boolean,
ipairs: (self: Set<T>) -> any,
}
type Iterable = { ipairs: (any) -> any }
function Set.new<T>(iterable: Array<T> | Set<T> | Iterable | string | nil): Set<T>
local array = {}
local map = {}
if iterable ~= nil then
local arrayIterable: Array<any>
-- ROBLOX TODO: remove type casting from (iterable :: any).ipairs in next release
if typeof(iterable) == "table" then
if Array.isArray(iterable) then
arrayIterable = Array.from(iterable :: Array<any>)
elseif typeof((iterable :: Iterable).ipairs) == "function" then
-- handle in loop below
elseif _G.__DEV__ then
error("cannot create array from an object-like table")
end
elseif typeof(iterable) == "string" then
arrayIterable = Array.from(iterable :: string)
else
error(("cannot create array from value of type `%s`"):format(typeof(iterable)))
end
if arrayIterable then
for _, element in ipairs(arrayIterable) do
if not map[element] then
map[element] = true
table.insert(array, element)
end
end
elseif typeof(iterable) == "table" and typeof((iterable :: Iterable).ipairs) == "function" then
for _, element in (iterable :: Iterable):ipairs() do
if not map[element] then
map[element] = true
table.insert(array, element)
end
end
end
end
return (setmetatable({
size = #array,
_map = map,
_array = array,
}, Set) :: any) :: Set<T>
end
function Set:add(value)
if not self._map[value] then
-- Luau FIXME: analyze should know self is Set<T> which includes size as a number
self.size = self.size :: number + 1
self._map[value] = true
table.insert(self._array, value)
end
return self
end
function Set:clear()
self.size = 0
table.clear(self._map)
table.clear(self._array)
end
function Set:delete(value): boolean
if not self._map[value] then
return false
end
-- Luau FIXME: analyze should know self is Map<K, V> which includes size as a number
self.size = self.size :: number - 1
self._map[value] = nil
local index = table.find(self._array, value)
if index then
table.remove(self._array, index)
end
return true
end
-- Implements Javascript's `Map.prototype.forEach` as defined below
-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/forEach
function Set:forEach<T>(callback: callbackFnSet<T> | callbackFnWithThisArgSet<T>, thisArg: Object?): ()
if typeof(callback) ~= "function" then
error("callback is not a function")
end
return Array.forEach(self._array, function(value: T)
if thisArg ~= nil then
(callback :: callbackFnWithThisArgSet<T>)(thisArg, value, value, self)
else
(callback :: callbackFnSet<T>)(value, value, self)
end
end)
end
function Set:has(value): boolean
return self._map[value] ~= nil
end
function Set:ipairs()
return ipairs(self._array)
end
-- #endregion Set
-- #region Object
function Object.entries(value: string | Object | Array<any>): Array<any>
assert(value :: any ~= nil, "cannot get entries from a nil value")
local valueType = typeof(value)
local entries: Array<Tuple<string, any>> = {}
if valueType == "table" then
for key, keyValue in pairs(value :: Object) do
-- Luau FIXME: Luau should see entries as Array<any>, given object is [string]: any, but it sees it as Array<Array<string>> despite all the manual annotation
table.insert(entries, { key :: string, keyValue :: any })
end
elseif valueType == "string" then
for i = 1, string.len(value :: string) do
entries[i] = { tostring(i), string.sub(value :: string, i, i) }
end
end
return entries
end
-- #endregion
-- #region instanceOf
-- ROBLOX note: Typed tbl as any to work with strict type analyze
-- polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof
function instanceOf(tbl: any, class)
assert(typeof(class) == "table", "Received a non-table as the second argument for instanceof")
if typeof(tbl) ~= "table" then
return false
end
local ok, hasNew = pcall(function()
return class.new ~= nil and tbl.new == class.new
end)
if ok and hasNew then
return true
end
local seen = { tbl = true }
while tbl and typeof(tbl) == "table" do
tbl = getmetatable(tbl)
if typeof(tbl) == "table" then
tbl = tbl.__index
if tbl == class then
return true
end
end
-- if we still have a valid table then check against seen
if typeof(tbl) == "table" then
if seen[tbl] then
return false
end
seen[tbl] = true
end
end
return false
end
-- #endregion
function Map.new<K, V>(iterable: Array<Array<any>>?): Map<K, V>
local array = {}
local map = {}
if iterable ~= nil then
local arrayFromIterable
local iterableType = typeof(iterable)
if iterableType == "table" then
if #iterable > 0 and typeof(iterable[1]) ~= "table" then
error("cannot create Map from {K, V} form, it must be { {K, V}... }")
end
arrayFromIterable = Array.from(iterable)
else
error(("cannot create array from value of type `%s`"):format(iterableType))
end
for _, entry in ipairs(arrayFromIterable) do
local key = entry[1]
if _G.__DEV__ then
if key == nil then
error("cannot create Map from a table that isn't an array.")
end
end
local val = entry[2]
-- only add to array if new
if map[key] == nil then
table.insert(array, key)
end
-- always assign
map[key] = val
end
end
return (setmetatable({
size = #array,
_map = map,
_array = array,
}, Map) :: any) :: Map<K, V>
end
function Map:set<K, V>(key: K, value: V): Map<K, V>
-- preserve initial insertion order
if self._map[key] == nil then
-- Luau FIXME: analyze should know self is Map<K, V> which includes size as a number
self.size = self.size :: number + 1
table.insert(self._array, key)
end
-- always update value
self._map[key] = value
return self
end
function Map:get(key)
return self._map[key]
end
function Map:clear()
local table_: any = table
self.size = 0
table_.clear(self._map)
table_.clear(self._array)
end
function Map:delete(key): boolean
if self._map[key] == nil then
return false
end
-- Luau FIXME: analyze should know self is Map<K, V> which includes size as a number
self.size = self.size :: number - 1
self._map[key] = nil
local index = table.find(self._array, key)
if index then
table.remove(self._array, index)
end
return true
end
-- Implements Javascript's `Map.prototype.forEach` as defined below
-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
function Map:forEach<K, V>(callback: callbackFn<K, V> | callbackFnWithThisArg<K, V>, thisArg: Object?): ()
if typeof(callback) ~= "function" then
error("callback is not a function")
end
return Array.forEach(self._array, function(key: K)
local value: V = self._map[key] :: V
if thisArg ~= nil then
(callback :: callbackFnWithThisArg<K, V>)(thisArg, value, key, self)
else
(callback :: callbackFn<K, V>)(value, key, self)
end
end)
end
function Map:has(key): boolean
return self._map[key] ~= nil
end
function Map:keys()
return self._array
end
function Map:values()
return Array.map(self._array, function(key)
return self._map[key]
end)
end
function Map:entries()
return Array.map(self._array, function(key)
return { key, self._map[key] }
end)
end
function Map:ipairs()
return ipairs(self:entries())
end
function Map.__index(self, key)
local mapProp = rawget(Map, key)
if mapProp ~= nil then
return mapProp
end
return Map.get(self, key)
end
function Map.__newindex(table_, key, value)
table_:set(key, value)
end
local function coerceToMap(mapLike: Map<any, any> | Table<any, any>): Map<any, any>
return instanceOf(mapLike, Map) and mapLike :: Map<any, any> -- ROBLOX: order is preservered
or Map.new(Object.entries(mapLike)) -- ROBLOX: order is not preserved
end
-- local function coerceToTable(mapLike: Map<any, any> | Table<any, any>): Table<any, any>
-- if not instanceOf(mapLike, Map) then
-- return mapLike
-- end
-- -- create table from map
-- return Array.reduce(mapLike:entries(), function(tbl, entry)
-- tbl[entry[1]] = entry[2]
-- return tbl
-- end, {})
-- end
-- #region Tests to verify it works as expected
local function it(description: string, fn: () -> ())
local ok, result = pcall(fn)
if not ok then
error("Failed test: " .. description .. "\n" .. result)
end
end
local AN_ITEM = "bar"
local ANOTHER_ITEM = "baz"
-- #region [Describe] "Map"
-- #region [Child Describe] "constructors"
it("creates an empty array", function()
local foo = Map.new()
assert(foo.size == 0)
end)
it("creates a Map from an array", function()
local foo = Map.new({
{ AN_ITEM, "foo" },
{ ANOTHER_ITEM, "val" },
})
assert(foo.size == 2)
assert(foo:has(AN_ITEM) == true)
assert(foo:has(ANOTHER_ITEM) == true)
end)
it("creates a Map from an array with duplicate keys", function()
local foo = Map.new({
{ AN_ITEM, "foo1" },
{ AN_ITEM, "foo2" },
})
assert(foo.size == 1)
assert(foo:get(AN_ITEM) == "foo2")
assert(#foo:keys() == 1 and foo:keys()[1] == AN_ITEM)
assert(#foo:values() == 1 and foo:values()[1] == "foo2")
assert(#foo:entries() == 1)
assert(#foo:entries()[1] == 2)
assert(foo:entries()[1][1] == AN_ITEM)
assert(foo:entries()[1][2] == "foo2")
end)
it("preserves the order of keys first assignment", function()
local foo = Map.new({
{ AN_ITEM, "foo1" },
{ ANOTHER_ITEM, "bar" },
{ AN_ITEM, "foo2" },
})
assert(foo.size == 2)
assert(foo:get(AN_ITEM) == "foo2")
assert(foo:get(ANOTHER_ITEM) == "bar")
assert(foo:keys()[1] == AN_ITEM)
assert(foo:keys()[2] == ANOTHER_ITEM)
assert(foo:values()[1] == "foo2")
assert(foo:values()[2] == "bar")
assert(foo:entries()[1][1] == AN_ITEM)
assert(foo:entries()[1][2] == "foo2")
assert(foo:entries()[2][1] == ANOTHER_ITEM)
assert(foo:entries()[2][2] == "bar")
end)
-- #endregion
-- #region [Child Describe] "type"
it("instanceOf return true for an actual Map object", function()
local foo = Map.new()
assert(instanceOf(foo, Map) == true)
end)
it("instanceOf return false for an regular plain object", function()
local foo = {}
assert(instanceOf(foo, Map) == false)
end)
-- #endregion
-- #region [Child Describe] "set"
it("returns the Map object", function()
local foo = Map.new()
assert(foo:set(1, "baz") == foo)
end)
it("increments the size if the element is added for the first time", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
assert(foo.size == 1)
end)
it("does not increment the size the second time an element is added", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:set(AN_ITEM, "val")
assert(foo.size == 1)
end)
it("sets values correctly to true/false", function()
-- Luau FIXME: Luau insists that arrays can't be mixed type
local foo = Map.new({ { AN_ITEM, false :: any } })
foo:set(AN_ITEM, false)
assert(foo.size == 1)
assert(foo:get(AN_ITEM) == false)
foo:set(AN_ITEM, true)
assert(foo.size == 1)
assert(foo:get(AN_ITEM) == true)
foo:set(AN_ITEM, false)
assert(foo.size == 1)
assert(foo:get(AN_ITEM) == false)
end)
-- #endregion
-- #region [Child Describe] "get"
it("returns value of item from provided key", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
assert(foo:get(AN_ITEM) == "foo")
end)
it("returns nil if the item is not in the Map", function()
local foo = Map.new()
assert(foo:get(AN_ITEM) == nil)
end)
-- #endregion
-- #region [Child Describe] "clear"
it("sets the size to zero", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:clear()
assert(foo.size == 0)
end)
it("removes the items from the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:clear()
assert(foo:has(AN_ITEM) == false)
end)
-- #endregion
-- #region [Child Describe] "delete"
it("removes the items from the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:delete(AN_ITEM)
assert(foo:has(AN_ITEM) == false)
end)
it("returns true if the item was in the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
assert(foo:delete(AN_ITEM) == true)
end)
it("returns false if the item was not in the Map", function()
local foo = Map.new()
assert(foo:delete(AN_ITEM) == false)
end)
it("decrements the size if the item was in the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:delete(AN_ITEM)
assert(foo.size == 0)
end)
it("does not decrement the size if the item was not in the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:delete(ANOTHER_ITEM)
assert(foo.size == 1)
end)
it("deletes value set to false", function()
-- Luau FIXME: Luau insists arrays can't be mixed type
local foo = Map.new({ { AN_ITEM, false :: any } })
foo:delete(AN_ITEM)
assert(foo.size == 0)
assert(foo:get(AN_ITEM) == nil)
end)
-- #endregion
-- #region [Child Describe] "has"
it("returns true if the item is in the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
assert(foo:has(AN_ITEM) == true)
end)
it("returns false if the item is not in the Map", function()
local foo = Map.new()
assert(foo:has(AN_ITEM) == false)
end)
it("returns correctly with value set to false", function()
-- Luau FIXME: Luau insists arrays can't be mixed type
local foo = Map.new({ { AN_ITEM, false :: any } })
assert(foo:has(AN_ITEM) == true)
end)
-- #endregion
-- #region [Child Describe] "keys / values / entries"
it("returns array of elements", function()
local myMap = Map.new()
myMap:set(AN_ITEM, "foo")
myMap:set(ANOTHER_ITEM, "val")
assert(myMap:keys()[1] == AN_ITEM)
assert(myMap:keys()[2] == ANOTHER_ITEM)
assert(myMap:values()[1] == "foo")
assert(myMap:values()[2] == "val")
assert(myMap:entries()[1][1] == AN_ITEM)
assert(myMap:entries()[1][2] == "foo")
assert(myMap:entries()[2][1] == ANOTHER_ITEM)
assert(myMap:entries()[2][2] == "val")
end)
-- #endregion
-- #region [Child Describe] "__index"
it("can access fields directly without using get", function()
local typeName = "size"
local foo = Map.new({
{ AN_ITEM, "foo" },
{ ANOTHER_ITEM, "val" },
{ typeName, "buzz" },
})
assert(foo.size == 3)
assert(foo[AN_ITEM] == "foo")
assert(foo[ANOTHER_ITEM] == "val")
assert(foo:get(typeName) == "buzz")
end)
-- #endregion
-- #region [Child Describe] "__newindex"
it("can set fields directly without using set", function()
local foo = Map.new()
assert(foo.size == 0)
foo[AN_ITEM] = "foo"
foo[ANOTHER_ITEM] = "val"
foo.fizz = "buzz"
assert(foo.size == 3)
assert(foo:get(AN_ITEM) == "foo")
assert(foo:get(ANOTHER_ITEM) == "val")
assert(foo:get("fizz") == "buzz")
end)
-- #endregion
-- #region [Child Describe] "ipairs"
local function makeArray(...)
local array = {}
for _, item in ... do
table.insert(array, item)
end
return array
end
it("iterates on the elements by their insertion order", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:set(ANOTHER_ITEM, "val")
assert(makeArray(foo:ipairs())[1][1] == AN_ITEM)
assert(makeArray(foo:ipairs())[1][2] == "foo")
assert(makeArray(foo:ipairs())[2][1] == ANOTHER_ITEM)
assert(makeArray(foo:ipairs())[2][2] == "val")
end)
it("does not iterate on removed elements", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:set(ANOTHER_ITEM, "val")
foo:delete(AN_ITEM)
assert(makeArray(foo:ipairs())[1][1] == ANOTHER_ITEM)
assert(makeArray(foo:ipairs())[1][2] == "val")
end)
it("iterates on elements if the added back to the Map", function()
local foo = Map.new()
foo:set(AN_ITEM, "foo")
foo:set(ANOTHER_ITEM, "val")
foo:delete(AN_ITEM)
foo:set(AN_ITEM, "food")
assert(makeArray(foo:ipairs())[1][1] == ANOTHER_ITEM)
assert(makeArray(foo:ipairs())[1][2] == "val")
assert(makeArray(foo:ipairs())[2][1] == AN_ITEM)
assert(makeArray(foo:ipairs())[2][2] == "food")
end)
-- #endregion
-- #region [Child Describe] "Integration Tests"
-- it("MDN Examples", function()
-- local myMap = Map.new() :: Map<string | Object | Function, string>
-- local keyString = "a string"
-- local keyObj = {}
-- local keyFunc = function() end
-- -- setting the values
-- myMap:set(keyString, "value associated with 'a string'")
-- myMap:set(keyObj, "value associated with keyObj")
-- myMap:set(keyFunc, "value associated with keyFunc")
-- assert(myMap.size == 3)
-- -- getting the values
-- assert(myMap:get(keyString) == "value associated with 'a string'")
-- assert(myMap:get(keyObj) == "value associated with keyObj")
-- assert(myMap:get(keyFunc) == "value associated with keyFunc")
-- assert(myMap:get("a string") == "value associated with 'a string'")
-- assert(myMap:get({}) == nil) -- nil, because keyObj !== {}
-- assert(myMap:get(function() -- nil because keyFunc !== function () {}
-- end) == nil)
-- end)
it("handles non-traditional keys", function()
local myMap = Map.new() :: Map<boolean | number | string, string>
local falseKey = false
local trueKey = true
local negativeKey = -1
local emptyKey = ""
myMap:set(falseKey, "apple")
myMap:set(trueKey, "bear")
myMap:set(negativeKey, "corgi")
myMap:set(emptyKey, "doge")
assert(myMap.size == 4)
assert(myMap:get(falseKey) == "apple")
assert(myMap:get(trueKey) == "bear")
assert(myMap:get(negativeKey) == "corgi")
assert(myMap:get(emptyKey) == "doge")
myMap:delete(falseKey)
myMap:delete(trueKey)
myMap:delete(negativeKey)
myMap:delete(emptyKey)
assert(myMap.size == 0)
end)
-- #endregion
-- #endregion [Describe] "Map"
-- #region [Describe] "coerceToMap"
it("returns the same object if instance of Map", function()
local map = Map.new()
assert(coerceToMap(map) == map)
map = Map.new({})
assert(coerceToMap(map) == map)
map = Map.new({ { AN_ITEM, "foo" } })
assert(coerceToMap(map) == map)
end)
-- #endregion [Describe] "coerceToMap"
-- #endregion Tests to verify it works as expected

View File

@ -25,10 +25,17 @@ now_ms() {
ITERATION_COUNT=$4 ITERATION_COUNT=$4
START_TIME=$(now_ms) START_TIME=$(now_ms)
ARGS=( "$@" )
REST_ARGS="${ARGS[@]:4}"
valgrind \ valgrind \
--quiet \ --quiet \
--tool=cachegrind \ --tool=cachegrind \
"$1" "$2" >/dev/null "$1" "$2" $REST_ARGS>/dev/null
ARGS=( "$@" )
REST_ARGS="${ARGS[@]:4}"
TIME_ELAPSED=$(bc <<< "$(now_ms) - ${START_TIME}") TIME_ELAPSED=$(bc <<< "$(now_ms) - ${START_TIME}")