[v.73] SimpleSignal | Very Simple & Silly Fast Script Signal

Package Version 64:

  • Reverted back to the old method of precreating threads, but this time allows new threads to be created; this allows yielding functions to still work while still making unyielding functions fast as hell to fire

  • Changed the incorrect license text

This hopefully should be the last major update to the module I have to do :V I have other things to work on

Hey, it’s me again. Here are the benchmarks:

--!optimize 2
--!strict

-- yield test
local BadSignal = require(script.BadSignal)
local SimpleSignal = require(script.SimpleSignal)

local loops = 1000 

local Signal1 = BadSignal:new(1) -- 1 is to tell to not allocate a lot of slots
local Signal2 = SimpleSignal.new()

local t = os.clock()
for i = 1,loops do
	Signal1:Push(true,function(b:any) -- true because we call task.wait()
		local a = i*b::number
		task.wait()
	end)
end
print("Bad signal connect: ",os.clock()-t)
t = os.clock()
for i = 1,loops do
	Signal2:Connect(function(b:any)
		local a = i*b::number
		task.wait()
	end)
end
print("Simple signal connect: ",os.clock()-t)

local t = os.clock()
for i = 1,10 do
	Signal1:Fire(2)
end
print("Bad signal fire: ",os.clock()-t)
local t = os.clock()
for i = 1,10 do
	Signal2:Fire(2)
end
print("Simple signal fire: ",os.clock()-t)
Bad signal connect:  0.00020760000188602135
Simple signal connect:  0.0015738000001874752
Bad signal fire:  0.010159500001464039
Simple signal fire:  0.015135800000280142

^ Tested on LOQ 15IAX9
despite badsignal being 33% faster. I think the automatic yield detection most signal modules have is a lot more convenient than explicitly saying that your function yields. Although it depends from person to person

1 Like

Hi, are you sure the time advantage isn’t a fluke? I’ve had benchmark results done like this sometimes tell me 1 has an advantage over the other, but if you do it multiple times you’ll see the advantage isn’t consistent

Also yea, I want this module to work as closely to RBXScriptSignals as possible :V

If you really want to, you can run it multiple times on your own device. As mentioned, it is a crude benchmark. But even despite being crude, it is still enough to see the difference.

Also, are you really sure a 0.005 margin of 10000 thread runs is an inaccuracy of a benchmark?

Yes, it is a possible inaccuracy :V I’ve had 100k repeats still give me false time advantages

Gimme a minute to do my own benchmarks with a similar method

This is a test which averages 20 benchmark total times, and as you can see over 5 runs simplesignal is consistently faster (I do it this way because my pc is quite low-end and can’t handle much cpu usage all at once without completely maxxing the cpu)





I was gonna compare it to SignalX too but it caused Studio to freeze so i had to kill studio and restart it :frowning:

Send the code, i’ll test it on my device

Script:

--?module-ancestors {script}

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local SimpleSignal = require(ReplicatedStorage.SimpleSignal)
local Benchmark = require(script.Benchmark)
local SignalX = require(ReplicatedStorage.SignalX)
local BadSivnal = require(ReplicatedStorage.BadSivnal)

local C_AMOUNT = 1000
local FIRES = 10

local s = SimpleSignal.new()
local bads = BadSivnal:new()

for i = 1, C_AMOUNT do
	s:Connect(function(b)
		local a = i * b 
		task.wait()
	end)
	bads:Push(true, function(b)
		local a = i * b 
		task.wait()
	end)
end

local badsTotal = {}
local simpleTotal = {}

print("waiting...")
task.wait(5)
for i = 1, 20 do
	do
		local total = Benchmark.findavg(FIRES, "total", 10, 1, function()
			bads:Fire(2)
		end)
		table.insert(badsTotal, total)
	end
	do
		local total = Benchmark.findavg(FIRES, "total", 10, 1, function()
			s:Fire(2)
		end)
		table.insert(simpleTotal, total)
	end
end

print("simple fire: ", Benchmark.mean(simpleTotal, FIRES))
print("bads fire: ", Benchmark.mean(badsTotal, FIRES))

Benchmark:

-- Benchmark utility module

local function table_sum(t)
	local sum = 0
	
	for _, v in t do
		sum += v
	end
	
	return sum
end

local function table_maxv(t)
	local max = -math.huge
	
	for _, v in t do
		if v > max then
			max = v
		end
	end
	
	return max
end

local function table_minv(t)
	local min = math.huge
	
	for _, v in t do
		if v < min then
			min = v
		end
	end
	
	return min
end

local function table_mode(t)
	local counts = {}
	local maxCount = 0
	local modeValue = 0

	for _, value in t do
		counts[value] = (counts[value] or 0) + 1
		if counts[value] > maxCount then
			maxCount = counts[value]
			modeValue = value
		end
	end

	return modeValue -- Return only the first most popular mode
end

local function dround(n: number, r: number)
	local p = 10^r
	
	return math.floor(n * p) / p
end

-- Returns the time it took <code>testfn</code> to run after the initial call.
local function benchmark_start<A..., R...>(testfn: (A...) -> R..., ...: A...): (number, R...)
	local start = os.clock()
	
	local result = {testfn(...)}
	
	return os.clock() - start, unpack(result)
end

--[[
	Calls <code>testfn</code> <code>repeats</code> times and returns <code>avtype</code> elapsed time (truncated to <code>decimals</code> decimal places).
]]
local function benchmark_findavg(repeats: number, avtype: "mean"|"max"|"min"|"median"|"mode"|"total", decimals: number, benchInterval: number, testfn: () -> ()): (number, {number})
	local times = {}
	local total = 0
	
	for i = 1, repeats do
		local t = benchmark_start(testfn)
		total += t
		
		table.insert(times, t)
		if i % benchInterval == 0 then
			task.wait()
		end
	end
	
	table.sort(times)
	if avtype == "max" then
		return dround(table_maxv(times), decimals), times
	elseif avtype == "min" then
		return dround(table_minv(times), decimals), times
	elseif avtype == "median" then
		return dround(times[#times//2], decimals), times
	elseif avtype == "mean" then
		return dround(table_sum(times)/repeats, decimals), times
	elseif avtype == "mode" then
		return dround(table_mode(times), decimals), times
	elseif avtype == "total" then
		return dround(total, decimals), times
	else
		error(`Unknown avtype {avtype}`)
	end
end

local function stdDeviation(times, mean)
	local sum = 0
	for _, v in times do
		sum += (v - mean)^2
	end
	return math.sqrt(sum/#times)
end

local Benchmark = {
	findavg = benchmark_findavg,
	start = benchmark_start,
	stdDeviation = stdDeviation,
	mean = function(t, repeats)
		return table_sum(t)/repeats
	end,
	--[[<docstring>
		hi
	]]
	cool_var = 10
}

return Benchmark

For other people who are still skeptical of either one or both of our claims you can test it yourself too :V

1 Like

Your benchmark is weird. I don’t know why; I don’t want to know why. But for whatever reason if you set benchInterval to 0, it shows completely different results

1 Like

Yes, because here I’m trying to measure individual fire times, if you do it en masse SimpleSignal is indeed slightly slower because it uses task.spawn() instead of BadSivnal (which uses coroutine.create()) on new threads to get proper error handling (this is because of the way Roblox handles task-scheduled threads, they are executed at specific resumption points which may cause there to be multiple thread resumptions at once); if you switch it for coroutine.create() you can indeed see the fire times is roughly the same :V

Oof, i just realised the reason simplesignal is faster is probably because the wait already ended before I fired again and therefor the module can just keep reusing the main thread :skull: Yeah SimpleSignal is indeed slower in this regard then, maybe I’ll switch to coroutine.create()

Just a note that I personally would continue using BindableEvent if not for deferred engine events. I think you need to consider the factor that there are a lot of people who are using signal modules not for speeds or memory optimizations. But due to deferred engine events. I think using task.spawn simply broke that factor

1 Like

Package Version 65:

  • Changed task.spawn in favor of coroutine.create and slightly modified the mainThreadWorker function to allow the module to more optimally resume and create threads

1000 fires w/ 1000 connections which have a task.wait(1) in them total time:
image

This should be the last last update :weary:

Package Version 69nice :

  • Added Signal:ConnectParallel() which will error if you try calling it from outside an Actor
  • Use guard clauses in Signal:Fire() to make more readable, and also more comments describing why certain things exist
  • Usage of more asserts instead of if statements
  • Moved license to its own module
  • Removed unecessary comments on the top of the module
  • And more little things I changed

I know i said 65 was gonna be the last but this is the last last ok nvm :slight_smile:

3 Likes

Thanks! this project is really useful :smiley:

1 Like

Package Version 73:

  • Added a useTaskLib parameter for SimpleSignal.new() which is true by default, if it is true then threads are created via task.spawn(), else they are created via coroutine.create(). This is intended to give people a choice between proper error handling or speed
1 Like