New Features in ScriptProfiler


Hello Creators,

Recently we have been working on improving ScriptProfiler. We have made many improvements that are available today, as well as several features coming soon.

Available Now

Display total wall-clock duration of the completed profiling session

The total wall-clock duration of the session is now displayed at the top of the window in hours:minutes:seconds.milliseconds format.

Focus on subtree

Right-clicking on a node in the Callgraph view sets the focus on that node, displaying only itself and its child nodes. Right-clicking the same node again will reset the view, displaying the contents of the <root> node again.

Higher Sampling Frequency

The Freq: N KHz button toggles the profiler between recording samples at 1KHz, or 1000 times per second, and 10KHz. This higher sampling mode enables greater time precision at the cost of some performance.

Functions View

A flat view of functions is now available, listing all functions recorded during the session.

Live Update

The Live checkbox enables polling and refreshing of profiling data continuously, updating the view with new data each second.

Searching

The search bar is now functional, hiding any parts of the callgraph that do not lead to a node containing the name typed in the search field.

Averaging

The Average button toggles displaying timing values on an average of 1 second, 1 minute, 5 minutes, or 10 minutes based on the wall-clock duration of the entire session. Averaging using an option that is longer than the session duration will extrapolate timing values. For example, for a profiling session of 1 minute in length, averaging using the 5-minutes option will display timing values at 5x their recorded values.

Timed Profiling

The Time button enables automatically stopping the profiler after a set duration. A countdown timer, with the remaining time in the session, is displayed during profiling. Clicking Stop at any time will cancel the timer, and stop the profiler as usual.

Coming Soon

Export profiler data

The Export view enables saving the profiling data displayed in DevConsole to the Roblox Logs directory. Profiling data is encoded as a JSON string, the structure of which is described below.

Profiling Data Format

Example JSON Blob
{
  "Version":2,
  "SessionStartTime":1704850750514,
  "SessionEndTime":1704850751198,
  "Categories":
  [
    {"Name":"Parallel Luau","NodeId":4},
    {"Name":"Heartbeat","NodeId":1}
  ],
  "Nodes":
  [
      {"TotalDuration":2530,"FunctionIds":[1],"NodeIds":[2]},
      {"TotalDuration":2530,"FunctionIds":[2,5],"NodeIds":[3,7]},
      {"TotalDuration":1267},
      {"TotalDuration":7746,"FunctionIds":[3],"NodeIds":[5]},
      {"TotalDuration":7746,"FunctionIds":[4],"NodeIds":[6]},
      {"TotalDuration":7746},
      {"TotalDuration":1263,"FunctionIds":[6],"NodeIds":[8]},
      {"TotalDuration":1263,"FunctionIds":[7],"NodeIds":[9]},
      {"TotalDuration":1263,"FunctionIds":[8],"NodeIds":[10]},
      {"TotalDuration":1263}
  ],
  "Functions":
  [
    {"Name":"main","TotalDuration":2530},
    {"Source":"builtin_ManageCollaborators.rbxm.ManageCollaborators.Packages._Index.roblox_rodux-3.0.0.rodux.Store","Line":81,"TotalDuration":1267},
    {"Name":"Script","TotalDuration":7746},
    {"Source":"Workspace.Actor.Script","Line":1,"TotalDuration":7746},
    {"Source":"builtin_DeveloperInspector.rbxm.DeveloperInspector.Packages._Index.DeveloperFramework.DeveloperFramework.UI.Components.Grid","Line":221,"TotalDuration":1263},
    {"Source":"builtin_DeveloperInspector.rbxm.DeveloperInspector.Packages._Index.DeveloperFramework.DeveloperFramework.UI.Components.Grid","Name":"_update","Line":236,"TotalDuration":1263},
    {"Source":"builtin_DeveloperInspector.rbxm.DeveloperInspector.Packages._Index.DeveloperFramework.DeveloperFramework.UI.Components.Grid","Name":"_getRange","Line":277,"TotalDuration":1263},
    {"Source":"[C]","Name":"ScrollingFrame.CanvasPosition","TotalDuration":1263}
  ]
}

The Version field tracks the major version number of the format. This value is currently 2. The format is subject to change with new, optional fields that may be added at any time, but we expect these changes to remain compatible with tooling that adopts this format today.

A NodeId is a unique identifier for a given node. This identifier is a 1-based index into the Nodes array. Thus, to lookup the Node with NodeId 123, one can simply retrieve the 123rd element in Nodes.

Similarly, a FunctionId identifies a given function descriptor in the Functions array by indexing Functions by the FunctionId.

A Duration is an integer representing an amount of time in profiler ticks. Currently, each tick is equivalent to 1 microsecond. Thus, to convert a duration to seconds, it is simply Duration / 1e6.

SessionStartTime and SessionEndTime are timestamps in milliseconds since the Unix epoch.

Each object in the Categories array contains a Name field and a NodeId field. Each category is representative of each of the top-level categories displayed in ScriptProfiler. The associated NodeId maps to a top-level node in the callgraph with child nodes that refer to each of the functions and nodes that executed during that category’s part of the frame.

Each entry in Nodes represents a function executing at a particular point in the callgraph. Nodes will always have a TotalDuration field, describing the total time spent in that node, including time spent in any child nodes. A node may optionally have a FunctionIds array and a NodeIds array. Each entry in these arrays corresponds to a callee of the current node. For any given child of a node, the i-th index of each array corresponds to the function descriptor of the child in Functions, and the node descriptor of the child in Nodes. If one of these arrays are present, then the other is also present; additionally, the number of entries in each array will always match.

Each entry in Functions stores auxiliary information shared amongst nodes that describe source-location, name, line number, and the total aggregate time spent in the function, as well as additional flags. A function entry will always have a TotalDuration field. Source, Name, and Line are optional; they are only present when the profiler has access to the corresponding information.

A function may also have a Flags integer. This is a bitfield with the following potential bits:

0 IsNative; if set, this function was executed via NativeCodeGen
1 IsPlugin; if set, this function (and its descendants) was executed as part of a plugin

Flags uniquely identify a function. There may more than one function entry for the same exact function if one Lua thread had executed that function under NativeCodeGen, and another that had executed the function via the LuauVM interpreter.

Hide GC Overhead and Plugin Scripts

We will additionally have checkboxes that toggle hiding time spent in garbage collection, and time spent in plugin scripts. Timings displayed across the callgraph will adjust accordingly.

Coming Later

We are planning to make available a public API for plugin tools to control the profiler and access profiling data. We will have more details on this API in the near future.

Feedback

We are continuously considering feedback and requests to improve ScriptProfiler. Feel free to suggest improvements and report issues in the thread below.

133 Likes

This topic was automatically opened after 11 minutes.

Wow, these updates look awesome! Kudos to the team, I’m excited to make use of this.

5 Likes

So excited for this

Great job to all teams involved!

6 Likes

When will documentation be updated for the Developer Console page? (Developer Console | Documentation - Roblox Creator Hub) It is severely out-of-date and only includes the Log, Memory, and Network tabs. While documentation may exist for this specific feature, developers who are looking at the console in general will see information that is old, and not very useful.

12 Likes

That’s pretty cool, but I don’t use the Developer Console that much, sorry.
Good job anyways! :smiley:

7 Likes

Loving this update. Can’t wait for occlusion culling :pray:

10 Likes

this is great for our services

4 Likes

Yeah, there needs to be a significant increase in resources for learning these tools.

3 Likes

I’ve been coding on this website for 6+ years and I still have no idea how to use this lol

6 Likes

I would really appreciate if the developer console’s UI was remade. As it stands now, it’s especially hard to read with all the contrasting white lines. It would also be nice if it respected the background opacity user setting!

Another thing, there’s barely any documentation on most of the developer console features. Your left to just figure them out, which can be challenging. Personally, I have no idea what the script profiler does. Infact, all I know how to use is the output, and the rest is just noise.

10 Likes

Ty, would pair up nicely with the micro profiler.

2 Likes

This will make performance issues even easier to solve. Thanks for these recent changes!

2 Likes

very nice
hopefully roblox adds more features like this instead of keeping you locked out of “advanced” stuff like buffers

2 Likes

Fab changes! This will make debugging laggy scripts a ton easier

1 Like

Hey I would love to make use of these changes on low-performing phone devices, however the Start button is obscured by various other options. Can this please be fixed?

1 Like

This issue has now been fixed and we are continuing to work on additional improvements to the UI.

1 Like

Hello!
We have another Script Profiler update for you.

It is now possible to use our Script Profiler APIs in custom Roblox Studio plugins!

We will have official documentation pages to follow, but for now, a short overview of the new service API:

-- Begin profiling the specified client
ScriptProfilerService:ClientStart(player: Player, frequency: number?): ()
-- Stop profiling the specified client
ScriptProfilerService:ClientStop(player: Player): ()
-- Request collected profiling data from the client
ScriptProfilerService:ClientRequestData(player: Player): ()

-- Begin profiling on the server
ScriptProfilerService:ServerStart(frequency: number?): ()
-- Stop profiling on the server
ScriptProfilerService:ServerStop(): ()
-- Request collected profiling data from the server
ScriptProfilerService:ServerRequestData(): ()

-- Signaled after ClientRequestData/ServerRequestData
-- It is expected that the Player parameter is nil when replicated from a server
ScriptProfilerService.OnNewData: (player: Player, jsonString: string) -> ()

-- The JSON format is documented at https://create.roblox.com/docs/studio/optimization/scriptprofiler#exporting-profiling-data
-- Return type structure is described in the example below
ScriptProfilerService:DeserializeJSON(jsonString: string): ProfilingInfo

And here’s an example of a simple plugin that profiles Client and Server together and reports time taken by each function (inclusively):

--!strict
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Selection = game:GetService("Selection")
local ScriptProfiler = game:GetService("ScriptProfilerService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")

-- Script profiler data format
type CategoryInfo = {
	Name: string,
	NodeId: number,
}

type NodeInfo = {
	-- While in JSON we had two 'FunctionIds' and 'NodeIds' arrays, in a table we have 'Children'
	-- Key represents an index into 'Functions' and a value is an index into 'Nodes'
	Children: {[number]: number}?,
	TotalDuration: number,
	Duration: number,
}

type FunctionInfo = {
	Source: string?,
	Name: string?,
	Line: number?,
	TotalDuration: number,
	-- Compared to JSON, these two fields are deserialized from 'Flags' field
	IsNative: boolean?,
	IsPlugin: boolean?,
}

type ProfilingInfo = {
	Version: number,
	SessionStartTime: number?,
	SessionEndTime: number?,
	GCFuncId: number?,
	Categories: {CategoryInfo},
	Nodes: {NodeInfo},
	Functions: {FunctionInfo},
}

local toolbar = plugin:CreateToolbar("ScriptProfiler")

local startProfilingButton = toolbar:CreateButton("Start", "Start Profiling", "rbxassetid://14978048121")
local stopProfilingButton = toolbar:CreateButton("Stop", "Stop Profiling", "rbxassetid://14978048121")

startProfilingButton.ClickableWhenViewportHidden = true
stopProfilingButton.ClickableWhenViewportHidden = true

startProfilingButton.Enabled = true
stopProfilingButton.Enabled = false

function getPlayer()
	if RunService:IsClient() then
		print('profiling for LocalPlayer', Players.LocalPlayer)
		return Players.LocalPlayer
	end

	local players = Players:GetPlayers()

	if #players < 1 then
		print('no players present to profile')
	end

	local player = players[1]
	print('profiling for', player)
	return player
end

local function onStartProfilingButtonClicked()
	local player = getPlayer()
	ScriptProfiler:ClientStart(player)
	ScriptProfiler:ServerStart()

	startProfilingButton.Enabled = false
	stopProfilingButton.Enabled = true
end

local function onStopProfilingButtonClicked()
	local player = getPlayer()
	ScriptProfiler:ClientStop(player)
	ScriptProfiler:ClientRequestData(player)

	ScriptProfiler:ServerStop()
	ScriptProfiler:ServerRequestData()

	startProfilingButton.Enabled = true
	stopProfilingButton.Enabled = false
end

startProfilingButton.Click:Connect(onStartProfilingButtonClicked)
stopProfilingButton.Click:Connect(onStopProfilingButtonClicked)

-- Create new "DockWidgetPluginGuiInfo" object
local widgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float,
	false,   -- Widget will be initially enabled
	false,  -- Don't override the previous enabled state
	600,    -- Default width of the floating window
	400,    -- Default height of the floating window
	300,    -- Minimum width of the floating window
	200     -- Minimum height of the floating window
)

local HEIGHT = 20
local FONT_SIZE = 12
local function Text(label: string, dims: UDim2)
	local text = Instance.new("TextLabel")
	text.Text = label
	text.TextSize = FONT_SIZE
	text.Position = dims
	text.Size = UDim2.new(0, 0, 0, HEIGHT)
	text.AutomaticSize = Enum.AutomaticSize.X
	text.Visible = true
	text.TextXAlignment = Enum.TextXAlignment.Left
	text.BorderSizePixel = 0
	return text
end

local function ScrollingFrame(chdrn_fn: () -> {GuiObject})
	local frame = Instance.new("ScrollingFrame")
	frame.Size = UDim2.fromScale(1, 1)
	frame.AutomaticCanvasSize = Enum.AutomaticSize.Y

	local children = chdrn_fn()
	for _, child in children do
		child.Parent = frame
	end

	return frame
end

local function FunctionInfoRow(func: FunctionInfo, index: number): GuiObject
	local frame = Instance.new("Frame")
	frame.Size = UDim2.new(1, 0, 0, HEIGHT)

	local name = nil

	if func.Name then
		name = func.Name
	elseif func.Source and func.Line then
		name = `{func.Source}:{func.Line}`
	else
		name = "<anonymous>"
	end

	Text(name, UDim2.fromOffset(0, index * HEIGHT)).Parent = frame
	Text(string.format("%.2f", (func.TotalDuration or 0) * 1000), UDim2.new(1, -80, 0, index * HEIGHT)).Parent = frame

	return frame
end

local widgets = {}

local function onNewProfilingData(player: Player, data: string)	
	local data = ScriptProfiler:DeserializeJSON(data) :: ProfilingInfo

	local id = "ScriptProfilerWidget_" .. if player then tostring(player) else "Server"

	-- Clean up previous UI
	local prev = widgets[id]
	if prev then prev:Destroy() end

	-- Create new widget GUI
	local testWidget = plugin:CreateDockWidgetPluginGui(id, widgetInfo) :: DockWidgetPluginGui
	testWidget.Title = "Script Profiler - " .. if player then tostring(player) else "Server"

	ScrollingFrame(function()
		local tab = {} :: {GuiObject}
		for index, func in data.Functions do
			table.insert(tab, FunctionInfoRow(func, index))
		end
		return tab
	end).Parent = testWidget

	testWidget.Enabled = true

	widgets[id] = testWidget
end

ScriptProfiler.OnNewData:Connect(onNewProfilingData)

We imagine that the community will be able to elevate script profiling to another level!

16 Likes

This topic was automatically closed 120 days after the last reply. New replies are no longer allowed.