i understood, and im totally with you for that.
but trying to scarifies some couple frametime for DX its also fine too, as long as not exceeding the frametime budget.
and im a bit curios how my framework perform, and i tried to benchmark it and compare with normal instance.new, i know its not 1:1 to the real world, but it gives the idea how the reactivity perform.
and heres the result updating 100 times per frame with 1000 elements, running for 3x
=== AVERAGES OVER 3 RUNS === - Client - Client:224
10:39:55.250 Raw -> FPS: 104.0 | UPS: 10401.2 | HeapΔ: 6740.7 KB | Writes: 26224.3 - Client - Client:225
10:39:55.250 Framework Direct -> FPS: 103.9 | UPS: 10391.6 | HeapΔ: 8650.7 KB | Writes: 26243.0 - Client - Client:234
10:39:55.250 Framework React. -> FPS: 103.4 | UPS: 10338.5 | HeapΔ: 8866.0 KB | Writes: 26126.0 - Client - Client:243
10:39:55.251
=== RELATIVE PERFORMANCE (vs Raw) === - Client - Client:253
10:39:55.251 Direct: FPS 99.9% | UPS 99.9% | Heap 128.3% | Writes 100.1% - Client - Client:254
10:39:55.251 React.: FPS 99.4% | UPS 99.4% | Heap 131.5% | Writes 99.6% - Client - Client:263
is not that bad
and this is the benchmark code
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Framework = require(ReplicatedStorage.Shared.Lib.Katalyst)
local RunService = game:GetService("RunService")
local NUM_ELEMENTS = 1000
local UPDATES_PER_FRAME = 100
local TEST_DURATION = 5
local RUNS = 3
local playerGui = game.Players.LocalPlayer:WaitForChild("PlayerGui")
local SIZE_ON = UDim2.fromOffset(100, 100)
local SIZE_OFF = UDim2.fromOffset(0, 0)
local function heapKB()
return collectgarbage("count")
end
-- Shared toggle sequence (so all modes do the same updates)
local updateIndices = {}
do
math.randomseed(1234) -- deterministic for fairness
for frame = 1, 600 do -- ~10s worth of frames
local batch = {}
for j = 1, UPDATES_PER_FRAME do
batch[j] = math.random(1, NUM_ELEMENTS)
end
updateIndices[frame] = batch
end
end
----------------------------------------------------------------
-- RAW benchmark
----------------------------------------------------------------
local function benchmarkRaw()
local gui = Instance.new("ScreenGui", playerGui)
local parent = Instance.new("Frame", gui)
local children = {}
local frameCount, updates, engineWrites = 0, 0, 0
local startTime = tick()
for i = 1, NUM_ELEMENTS do
local child = Instance.new("Frame")
child.Size = SIZE_OFF
child.Position = UDim2.fromOffset((i % 40) * 10, math.floor(i / 40) * 10)
child.Parent = parent
children[i] = child
child:GetPropertyChangedSignal("Size"):Connect(function()
engineWrites += 1
end)
end
local conn
conn = RunService.RenderStepped:Connect(function()
frameCount += 1
local batch = updateIndices[(frameCount % #updateIndices) + 1]
for _, idx in ipairs(batch) do
local child = children[idx]
if updates % 2 == 0 then
child.Size = SIZE_ON
else
child.Size = SIZE_OFF
end
updates += 1
end
end)
task.wait(TEST_DURATION)
conn:Disconnect()
local elapsed = tick() - startTime
local fps = frameCount / elapsed
local ups = updates / elapsed
local endHeap = heapKB()
gui:Destroy()
return fps, ups, endHeap, engineWrites, nil -- no latency for raw
end
----------------------------------------------------------------
-- FRAMEWORK DIRECT benchmark
----------------------------------------------------------------
local function benchmarkFrameworkDirect()
local gui = Instance.new("ScreenGui", playerGui)
local parent = Instance.new("Frame", gui)
local proxies = {}
local frameCount, updates, engineWrites = 0, 0, 0
local startTime = tick()
for i = 1, NUM_ELEMENTS do
local proxy = Framework.New("Frame")({
Parent = parent,
Size = SIZE_OFF,
Position = UDim2.fromOffset((i % 40) * 10, math.floor(i / 40) * 10),
})
proxies[i] = proxy
proxy():GetPropertyChangedSignal("Size"):Connect(function()
engineWrites += 1
end)
end
local conn
conn = RunService.RenderStepped:Connect(function()
frameCount += 1
local batch = updateIndices[(frameCount % #updateIndices) + 1]
for _, idx in ipairs(batch) do
local inst = proxies[idx]()
if updates % 2 == 0 then
inst.Size = SIZE_ON
else
inst.Size = SIZE_OFF
end
updates += 1
end
end)
task.wait(TEST_DURATION)
conn:Disconnect()
local elapsed = tick() - startTime
local fps = frameCount / elapsed
local ups = updates / elapsed
local endHeap = heapKB()
gui:Destroy()
return fps, ups, endHeap, engineWrites, nil
end
----------------------------------------------------------------
-- FRAMEWORK REACTIVE benchmark
----------------------------------------------------------------
local function benchmarkFrameworkReactive()
local gui = Instance.new("ScreenGui", playerGui)
local parent = Instance.new("Frame", gui)
local states = {}
local frameCount, updates, engineWrites = 0, 0, 0
local startTime = tick()
for i = 1, NUM_ELEMENTS do
local s = Framework.State(SIZE_OFF)
local proxy = Framework.New("Frame")({
Parent = parent,
Size = s,
Position = UDim2.fromOffset((i % 40) * 10, math.floor(i / 40) * 10),
})
states[i] = s
proxy():GetPropertyChangedSignal("Size"):Connect(function()
engineWrites += 1
end)
end
local conn
conn = RunService.RenderStepped:Connect(function()
frameCount += 1
local batch = updateIndices[(frameCount % #updateIndices) + 1]
for _, idx in ipairs(batch) do
if updates % 2 == 0 then
states[idx].__lastSet = tick()
states[idx]:set(SIZE_ON)
else
states[idx].__lastSet = tick()
states[idx]:set(SIZE_OFF)
end
updates += 1
end
end)
task.wait(TEST_DURATION)
conn:Disconnect()
local elapsed = tick() - startTime
local fps = frameCount / elapsed
local ups = updates / elapsed
local endHeap = heapKB()
gui:Destroy()
return fps, ups, endHeap, engineWrites
end
----------------------------------------------------------------
-- Multi-run driver
----------------------------------------------------------------
local function average(list)
local sumFPS, sumUPS, sumHeap, sumWrites, sumLatency = 0, 0, 0, 0, 0
for _, v in ipairs(list) do
sumFPS += v[1]
sumUPS += v[2]
sumHeap += v[3]
sumWrites += v[4]
if v[5] then
sumLatency += v[5]
end
end
return sumFPS / #list, sumUPS / #list, sumHeap / #list, sumWrites / #list
end
task.delay(6, function()
local rawResults, fwDirectResults, fwReactiveResults = {}, {}, {}
for run = 1, RUNS do
print("=== RAW Run " .. run .. " ===")
table.insert(rawResults, { benchmarkRaw() })
task.wait(1)
print("=== FRAMEWORK DIRECT Run " .. run .. " ===")
table.insert(fwDirectResults, { benchmarkFrameworkDirect() })
task.wait(1)
print("=== FRAMEWORK REACTIVE Run " .. run .. " ===")
table.insert(fwReactiveResults, { benchmarkFrameworkReactive() })
task.wait(1)
end
local rawFPS, rawUPS, rawHeap, rawWrites = average(rawResults)
local dirFPS, dirUPS, dirHeap, dirWrites = average(fwDirectResults)
local reaFPS, reaUPS, reaHeap, reaWrites = average(fwReactiveResults)
print("\n=== AVERAGES OVER " .. RUNS .. " RUNS ===")
print(
string.format(
"Raw -> FPS: %.1f | UPS: %.1f | HeapΔ: %.1f KB | Writes: %.1f",
rawFPS,
rawUPS,
rawHeap,
rawWrites
)
)
print(
string.format(
"Framework Direct -> FPS: %.1f | UPS: %.1f | HeapΔ: %.1f KB | Writes: %.1f",
dirFPS,
dirUPS,
dirHeap,
dirWrites
)
)
print(
string.format(
"Framework React. -> FPS: %.1f | UPS: %.1f | HeapΔ: %.1f KB | Writes: %.1f",
reaFPS,
reaUPS,
reaHeap,
reaWrites
)
)
print("\n=== RELATIVE PERFORMANCE (vs Raw) ===")
print(
string.format(
"Direct: FPS %.1f%% | UPS %.1f%% | Heap %.1f%% | Writes %.1f%%",
(dirFPS / rawFPS) * 100,
(dirUPS / rawUPS) * 100,
(dirHeap / rawHeap) * 100,
(dirWrites / rawWrites) * 100
)
)
print(
string.format(
"React.: FPS %.1f%% | UPS %.1f%% | Heap %.1f%% | Writes %.1f%%",
(reaFPS / rawFPS) * 100,
(reaUPS / rawUPS) * 100,
(reaHeap / rawHeap) * 100,
(reaWrites / rawWrites) * 100
)
)
end)
if something that you can improve for my benchmark, just let me know.
(edit: i forgot to turn off default styling override)