diff --git a/benchmarks/bench.luau b/benchmarks/bench.luau new file mode 100644 index 0000000..8c14b30 --- /dev/null +++ b/benchmarks/bench.luau @@ -0,0 +1,48 @@ +local profiles: { [string]: number } = {} +local times: { [string]: number } = {} +local stack: { string } = {} + +local function run(name: string, test: () -> ()) + local start = os.clock() + local iter = 0 + + table.clear(profiles) + table.clear(times) + table.clear(stack) + + while os.clock() - start < 1 do + iter += 1 + test() + end + + local elapsed = os.clock() - start + local results = {} + + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + + for name, elapsed in times do + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + end + + print(table.concat(results, " | ")) +end + +local function profilebegin(name: string) + profiles[name] = os.clock() + table.insert(stack, name) +end + +local function profileend() + local name = table.remove(stack) + + if name then + times[name] = (times[name] or 0) + os.clock() - profiles[name] + profiles[name] = nil + end +end + +return { + run = run, + profilebegin = profilebegin, + profileend = profileend, +} diff --git a/benchmarks/mapped.bench.luau b/benchmarks/mapped.bench.luau new file mode 100644 index 0000000..89fdfad --- /dev/null +++ b/benchmarks/mapped.bench.luau @@ -0,0 +1,99 @@ +--!optimize 2 + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.Packages.Charm) + +local atom = Charm.atom +local computed = Charm.computed +local mapped = Charm.mapped +local pauseTracking = Charm.signals.pauseTracking +local resumeTracking = Charm.signals.resumeTracking + +type Entry = { number } + +local count = 512 +local initialState: { Entry } = table.create(count) + +for i = 1, count do + initialState[i] = table.create(32, i) +end + +local function mapper(value: Entry): Entry + return table.clone(value) +end + +local function simpleMapped(source: () -> { Entry }): () -> { Entry } + return computed(function() + local state = source() + local nextState = {} + + for index, value in state do + pauseTracking() + local success, valueOut = pcall(mapper, value) + resumeTracking() + + if success then + nextState[index] = valueOut + end + end + + return state + end) +end + +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } + return function(state) + local nextState = table.clone(state) + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) + end + end + return nextState + end +end + +local function test( + percent: number, + profiler: any, + mapped: (src: () -> { Entry }, ...(Entry) -> Entry) -> () -> { Entry } +) + local src = atom(initialState) + local out = mapped(src, mapper) + + profiler.Begin("init") + out() + profiler.End() + + profiler.Begin("setup") + src(updatePercentage(percent)) + profiler.End() + + profiler.Begin("update") + out() + profiler.End() +end + +return { + Functions = { + ["mapped 10%"] = function(profiler) + test(0.1, profiler, mapped) + end, + ["mapped 50%"] = function(profiler) + test(0.5, profiler, mapped) + end, + ["mapped 100%"] = function(profiler) + test(1, profiler, mapped) + end, + ["control 10%"] = function(profiler) + test(0.1, profiler, simpleMapped) + end, + ["control 50%"] = function(profiler) + test(0.5, profiler, simpleMapped) + end, + ["control 100%"] = function(profiler) + test(1, profiler, simpleMapped) + end, + }, +} diff --git a/benchmarks/mapped.luau b/benchmarks/mapped.luau new file mode 100644 index 0000000..327197d --- /dev/null +++ b/benchmarks/mapped.luau @@ -0,0 +1,80 @@ +local Charm = require("@pkgs/charm/src/lib") +local bench = require("./bench") + +local atom = Charm.atom +local computed = Charm.computed +local mapped = Charm.mapped +local pauseTracking = Charm.signals.pauseTracking +local resumeTracking = Charm.signals.resumeTracking + +type Entry = { number } + +local count = 512 +local initialState: { Entry } = table.create(count) + +for i = 1, count do + initialState[i] = table.create(64, i) +end + +local function mapper(value: Entry, key: number): (Entry, number) + return table.clone(value), key + 1 +end + +local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry } + return computed(function() + local state = source() + local nextState = {} + + for index, value in state do + pauseTracking() + local success, valueOut, keyOut = pcall(mapper, value, index) + resumeTracking() + + if success then + nextState[keyOut] = valueOut + end + end + + return state + end) +end + +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } + return function(state) + local nextState = table.clone(state) + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) + end + end + return nextState + end +end + +for _, percent in { 0.1, 0.5, 1 } do + bench.run(`mapped {percent}`, function() + local src = atom(initialState) + local out = mapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + src(updatePercentage(percent)) + bench.profilebegin("update") + out() + bench.profileend() + end) + + bench.run(`control {percent}`, function() + local src = atom(initialState) + local out = simpleMapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + src(updatePercentage(percent)) + bench.profilebegin("update") + out() + bench.profileend() + end) +end diff --git a/pkgs/charm-sync/benchmarks/patch.bench.luau b/benchmarks/patch.bench.luau similarity index 100% rename from pkgs/charm-sync/benchmarks/patch.bench.luau rename to benchmarks/patch.bench.luau diff --git a/pkgs/charm/benchmarks/propagate.bench.luau b/benchmarks/propagate.bench.luau similarity index 100% rename from pkgs/charm/benchmarks/propagate.bench.luau rename to benchmarks/propagate.bench.luau diff --git a/benchmarks/propagate.luau b/benchmarks/propagate.luau new file mode 100644 index 0000000..a08e8b1 --- /dev/null +++ b/benchmarks/propagate.luau @@ -0,0 +1,27 @@ +local Charm = require("@pkgs/charm/src/lib") +local bench = require("./bench") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +for _, w in { 1, 10, 100 } do + for _, h in { 1, 10, 100 } do + bench.run(`propagate {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) + end +end diff --git a/default.project.json b/default.project.json index 08fc194..904e9cd 100644 --- a/default.project.json +++ b/default.project.json @@ -22,9 +22,7 @@ "VideCharm": { "$path": "pkgs/vide-charm/tests" } }, "Benchmarks": { - "$className": "Folder", - "Charm": { "$path": "pkgs/charm/benchmarks" }, - "CharmSync": { "$path": "pkgs/charm-sync/benchmarks" } + "$path": "benchmarks" } } } diff --git a/pkgs/charm/benchmarks/mapped.bench.luau b/pkgs/charm/benchmarks/mapped.bench.luau deleted file mode 100644 index cf1a454..0000000 --- a/pkgs/charm/benchmarks/mapped.bench.luau +++ /dev/null @@ -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, - }, -} diff --git a/pkgs/charm/benchmarks/propagate.luau b/pkgs/charm/benchmarks/propagate.luau deleted file mode 100644 index b6780f5..0000000 --- a/pkgs/charm/benchmarks/propagate.luau +++ /dev/null @@ -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) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index b871aa5..6869f5e 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -1,3 +1,5 @@ +--!optimize 2 + local signals = require("./signals") local computed = signals.computed @@ -8,12 +10,12 @@ type Mapped = & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO } +local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }): { [KO]: VO } return if output == previous then table.clone(output) else output end local function mapped(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(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(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 diff --git a/pkgs/charm/tests/mapped.spec.luau b/pkgs/charm/tests/mapped.spec.luau new file mode 100644 index 0000000..be2821c --- /dev/null +++ b/pkgs/charm/tests/mapped.spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index 7a60b4d..fc8f473 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -16,8 +16,9 @@ end collect("signals") spec("gc") -spec("subscribe") spec("listen") +spec("mapped") spec("observe") +spec("subscribe") return {}