perf: optimize mapped, add benches

This commit is contained in:
littensy 2025-05-06 16:27:06 -07:00
parent d56672384a
commit ada1b80b87
12 changed files with 385 additions and 161 deletions

View file

@ -1,54 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local patch = CharmSync.patch
local USERS = 1000
local function generate()
local users = {}
for user = 1, USERS do
users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
return { users = users }
end
local function update(state)
state = table.clone(state)
state.users = table.clone(state.users)
for user = 1, USERS do
local rng = math.random()
if rng < 1 / 3 then
continue
elseif rng < 2 / 3 then
state.users[user] = nil
else
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
end
return state
end
return {
ParameterGenerator = function()
local state = generate()
return state, update(state)
end,
Functions = {
diff = function(_, a, b)
patch.diff(a, b)
end,
},
}

View file

@ -1,79 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local mapped = Charm.mapped
type Person = {
name: string,
age: number,
}
local personCount = 1000
local initialState: { Person } = table.create(personCount)
for i = 1, personCount do
initialState[i] = {
name = "Person " .. i,
age = math.random(1, 100),
}
end
local function mapper(person: Person): Person
return {
name = person.name .. "!",
age = person.age + 1,
}
end
local function simpleMapped(source: () -> { Person }): () -> { Person }
return computed(function()
local state = table.clone(source())
for index, person in state do
state[index] = mapper(person)
end
return state
end)
end
local function updatePercentageOfPeople(percent: number): ({ Person }) -> { Person }
return function(state)
local nextState = table.clone(state)
for index, person in state do
if math.random() < percent then
nextState[index] = table.clone(person)
end
end
return nextState
end
end
return {
Functions = {
["mapped 50%"] = function(profiler)
profiler.Begin("init")
local src = atom(initialState)
local out = mapped(src, mapper)
out()
profiler.End()
profiler.Begin("update")
src(updatePercentageOfPeople(0.5))
profiler.End()
end,
["simple 50%"] = function(profiler)
profiler.Begin("init")
local src = atom(initialState)
local out = simpleMapped(src)
out()
profiler.End()
profiler.Begin("update")
src(updatePercentageOfPeople(0.5))
profiler.End()
end,
},
}

View file

@ -1,63 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local function propagate(w: number, h: number)
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end
local function propagateListen(w: number, h: number)
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
listen(last, function() end)
end
src(src() + 1)
end
return {
Functions = {
["effect 1*100"] = function()
propagate(1, 100)
end,
["effect 100*1"] = function()
propagate(100, 1)
end,
["effect 10*10"] = function()
propagate(10, 10)
end,
["listen 1*100"] = function()
propagateListen(1, 100)
end,
["listen 100*1"] = function()
propagateListen(100, 1)
end,
["listen 10*10"] = function()
propagateListen(10, 10)
end,
},
}

View file

@ -1,74 +0,0 @@
local Charm = require("@pkgs/charm/src/lib")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local signal = Charm.signals.signal
local computedSignal = Charm.signals.computed
local effectSignal = Charm.signals.effect
local w = 10
local h = 10
local function bench(name: string, test: () -> ())
local start = os.clock()
local iter = 0
while os.clock() - start < 2 do
iter += 1
test()
end
local endTime = os.clock()
local elapsed = endTime - start
print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`)
end
bench(`listen {w}*{h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
listen(last, function() end)
end
src(src() + 1)
end)
bench(`effect {w}*{h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end)
bench(`signals {w}*{h}`, function()
local src, setSrc = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computedSignal(function()
return prev() + 1
end)
end
effectSignal(function()
last()
end)
end
setSrc(src() + 1)
end)

View file

@ -1,3 +1,5 @@
--!optimize 2
local signals = require("./signals")
local computed = signals.computed
@ -8,12 +10,12 @@ type Mapped =
& (<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO })
& (<K, VI, VO>(selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO })
local function ensureCanMutateOutput<KO, VO>(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO }
local function ensureCanMutateOutput<KO, VO>(output: { [KO]: VO }, previous: { [KO]: VO }): { [KO]: VO }
return if output == previous then table.clone(output) else output
end
local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
local currentData: { [KI]: VI } = {}
local currentData: { [KI]: VI }
local keyCache: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO)
@ -29,10 +31,26 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (ite
end
return computed(function(previousOutput)
local output: { [KO]: VO } = previousOutput or {}
local previousData = currentData
currentData = selector()
-- Only perform additions if this is the first run
if not previousOutput then
local output: { [KO]: VO } = {}
for key, value in currentData do
local valueOut, keyOut = map(value, key)
if valueOut ~= nil then
output[keyOut] = valueOut
keyCache[key] = keyOut
end
end
return output
end
local output = previousOutput
-- Check equality since the selector might not be a computed value
if currentData == previousData then
return output
@ -42,7 +60,6 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (ite
for key in previousData do
if currentData[key] == nil then
local keyOut = keyCache[key]
if keyOut then
output = ensureCanMutateOutput(output, previousOutput)
output[keyOut] = nil

View file

@ -0,0 +1,107 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local atom = Charm.atom
local mapped = Charm.mapped
local test = suite.test
test("should map atom", function()
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6 })
assert(table.concat(map()) == "567", table.concat(map()))
end)
test("should not map unchanged values", function()
local calls = 0
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
assert(calls == 3, "should map 3 values")
src({ 1, 3, 3 })
assert(table.concat(map()) == "244", table.concat(map()))
assert(calls == 4, "should map 1 change")
src({})
assert(#map() == 0, "should be empty")
assert(calls == 4, "should not map for removed values")
end)
test("should not update if no values change", function()
local calls = 0
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
local prev = map()
assert(calls == 3, "should map 3 values")
assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6, 7 })
src({ 1, 2, 3 })
assert(calls == 3, "should not map unchanged values")
assert(table.concat(map()) == "234", table.concat(map()))
assert(prev == map(), "should not create new table")
end)
test("should filter values", function()
local calls = 0
local src = atom({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number)
calls += 1
return if value < 0 then nil else value
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(state.a and state.b and state.c, "should map all values")
calls = 0
src({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.a == nil, "should remove a")
assert(state.b and state.c, "should keep b and c")
assert(state.d == 4, "should add d")
end)
test("should map keys", function()
local calls = 0
local src = atom({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number, key: string)
calls += 1
return if value < 0 then nil else value, string.upper(key)
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(not (state.a and state.b and state.c), "should discard old keys")
assert(state.A == 1, "should map a")
assert(state.B == 2, "should map b")
assert(state.C == 3, "should map c")
calls = 0
src({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.A == nil and state.a == nil, "should remove a")
assert(state.B and state.C, "should keep b and c")
assert(state.D == 4, "should add d")
end)
return {}

View file

@ -16,8 +16,9 @@ end
collect("signals")
spec("gc")
spec("subscribe")
spec("listen")
spec("mapped")
spec("observe")
spec("subscribe")
return {}