forked from TurkeyMcMac/jitprofiler
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinit.lua
More file actions
602 lines (507 loc) · 21 KB
/
init.lua
File metadata and controls
602 lines (507 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
-- NOTE: everything must be in this file, unless you want the profiler_record
-- hook to be slower filtering out jitprofiler files.
if not core.global_exists("jit") then
core.log("warning", "[jitprofiler] " ..
"Since LuaJIT is not being used, this mod is disabled.")
return
end
local MP = core.get_modpath(core.get_current_modname()):gsub('\\', '/') ---@type string
local STORAGE = core.get_mod_storage()
-- mapping between frame and the actual function
local frame_map = {
-- it is actually fast_new, but engine code often use this through push_vector so yeah
-- deactivated for now
-- [';@B/common/vector.lua:416'] = ';@B/common/vector.lua:push_vector'
}
-- filter out noisy and unhelpful stack dumps
local stack_filter = {
'LUANTI;@B/common/register%.lua:core%.run_callbacks %d+\n',
'LUANTI;@B/common/vector%.lua:push_vector %d+\n',
}
local function escape_patterns(s)
return s:gsub("([%^$()%%.%[%]*+?-])", "%%%1")
end
-- these are used to remove clutter in the <module> part of each stack frame
-- most of these are not needed for RUN_IN_PLACE builds (primarily windows)
-- but needed for system installs (primarily linux)
-- won't work for added mod paths
-- *_PATH/*_PARENT are forward slash normalized, but unresolved and preserve duped path separators
-- also they have trailing slash
-- <luanti-sys>/builtin/common/vector.lua
---@type string
local SYSTEM_PATH = debug.getinfo(vector.new, "S").source
:gsub('\\', '/'):match("@(.+/)builtin/common/vector%.lua$")
local SYSTEM_PATH_PAT = ';' ..escape_patterns(SYSTEM_PATH)
-- <luanti-mod>/mods/jitprofiler
local MODS_PARENT = MP:match('(.+/)mods/jitprofiler$') ---@type string
local MODS_PARENT_PAT = ';' ..escape_patterns(MODS_PARENT)
-- <luanti-game>/games/<game>
---@type string
local GAMES_PARENT = core.get_game_info().path:gsub('\\', '/'):match('(.+/)games/')
local GAMES_PARENT_PAT = ';' ..escape_patterns(GAMES_PARENT)
-- <luanti-world>/worlds/<worldname>/
local WORLD_PATH = core.get_worldpath():gsub('\\', '/') ..'/'
local WORLD_PATH_PAT = ';' ..escape_patterns(WORLD_PATH)
---@type string
local GAME_MODS_PATH = ';games/' ..core.get_game_info().id ..'/mods'
local function ms_clock()
return core.get_us_time() / 1e3
end
---@param ms number
local function pretty_time(ms)
if ms >= 1e5 then
return ('%ds'):format(math.floor(ms / 1e3))
elseif ms >= 1e3 then
return string.format(
'%.' ..(5 - math.max(0, math.floor(math.log10(math.abs(ms))))) ..'fs',
ms / 1e3)
else
return ('%dms'):format(math.floor(ms))
end
end
local pretty_mem
do
-- it's going to do a table lookup anyways
local byte_div = {
[0] = 1, 1, 1,
1e3, 1e3, 1e3,
1e6, 1e6, 1e6
}
local byte_sizes = {
[0] = '', '', '',
'K', 'K', 'K',
'M', 'M', 'M',
}
---@param sz integer
function pretty_mem(sz)
if sz < 1e3 then
return tostring(sz) ..'B'
end
local i = math.floor(math.log10(sz))
return ('%.2f' ..byte_sizes[i] ..'B'):format(sz / byte_div[i])
end
end
local function norm_path(path)
return path:gsub('\\', '/'):gsub('/+', '/')
end
---@param path string
local function resolve_path(path)
-- TODO very fragile stuff
if not (path:sub(1,1) == '/' or path:sub(2,3) == ':/' or path:sub(2,3) == ':\\') then
error('cannot resolve relative paths')
end
local path = path
:gsub('/%.$', ''):gsub('/%./', '/')
:gsub('[^/]+/%.%.$', ''):gsub('[^/]+/%.%./', '')
assert(path:find('/../', nil, true) == nil, 'could not resolve .. inside a path/filename')
return path
end
local DATA_DIR = resolve_path(norm_path(core.get_worldpath())) .. "/jitprofiles"
assert(core.mkdir(DATA_DIR), "Could not create profile directory " .. DATA_DIR)
local MAPPINGS_DIR = resolve_path(core.get_mod_data_path()) .. "/mappings"
assert(core.mkdir(MAPPINGS_DIR), "Could not create profile directory " .. MAPPINGS_DIR)
---@param path string
---@param open function
local function read_file(path, open, reason)
local file = open(path, 'r') ---@type file*?
if not file then error('unable to read file ' ..reason ..': ' ..path) end
local data = file:read("*a") ---@type string?
if not data then error('unable to read file ' ..reason ..': ' ..path) end
file:close()
if data:sub(#data) ~= '\n' then data = data ..'\n' end
return data
end
---@param path string
---@param open function
---@param data string|string.buffer
local function write_file(path, open, reason, data)
local file = open(path, 'w') ---@type file*?
if not file then error('unable to write file ' ..reason ..': ' ..path) end
---@cast data string
if not file:write(data) then error('unable to write file ' ..reason ..': ' ..path) end
file:close()
end
-- these are parallel lists. they are implicitly linked.
local zone_queue = false
local zones = {}
local zones_hashmap = {}
local zone_points = {} -- (check)point being the stack when zone is called
-- NOTE: It is important that zones are fast
local function zone_noop() end
--- @param name string
local function zone_start(name)
local count = #zones
if name:find('[^A-Za-z0-9_ :.-]') then
error(('bad characters in zone %q. Allowed chars are "-A-Za-z0-9_ :."'):format(name))
end
if zones_hashmap[name] then
error(('attempt to start zone %q twice! are you missing a zone_stop()?'):format(name))
end
count = count + 1
zones_hashmap[name] = true
zones[count] = name
zone_queue = true
-- while zone_queue do end -- force the profiler to record
-- local info = debug.getinfo(2, "Sn")
-- local name = info.name
-- if not name or name == '' then name = tostring(info.linedefined) end
-- zone_points[count] = (';%s:%s'):format(info.source:sub(2), name)
-- core.chat_send_all(zone_points[count])
end
--- @param name string
local function zone_stop(name)
local count = #zones
if not zones_hashmap[name] then
error(('attempt to end the zone %q without starting it! are you missing a zone_start()?')
:format(name))
end
if zones[count] ~= name then
error(('attempting to stop the zone %q without stopping its children'):format(name))
end
zones_hashmap[name] = nil
zones[count] = nil
zone_points[count] = nil
count = count - 1
end
local profiler_start, profiler_stop, profiler_learn
local raw_data_buffer ---@type string.buffer?
do
local ie = core.request_insecure_environment()
if not ie then error("To use jitprofiler, add it to secure.trusted_mods") end
local require = ie.require
local strbuf = require('string.buffer')
local profile = require("jit.profile")
local jpstart, jpstop, jpdumpstack = profile.start, profile.stop, profile.dumpstack
local danger_open = ie.io.open
local clock = core.get_us_time ---@type fun(): number
local time_point ---@type number
local scorer
local function default_scorer(time, samples)
return time / samples * 0.01
end
-- this shows when the sample is collected, not how much time spent inside.
local vmstate_mapping = {
N = ';(recorded during JIT compiled trace)',
I = ';(recorded while interpreting)',
C = ';(recorded via C/C++ call)',
J = ';(recorded while compiling trace)',
G = ';(recorded while collecting garbage)',
}
local profiler_record
-- NOTE: It is *VERY* important that profiler_record do as less as possible
-- in order to allow for lower intervals/higher frequencies of sampling.
function profiler_record(thread, samples, vmstate)
local stack = jpdumpstack(thread, "pFZ;", -100)
local _, found_mod = string.find(norm_path(stack), 'mods/jitprofiler/init.lua', nil, true)
if found_mod then
time_point = clock()
return
end
-- TODO figure out a way to make zones work
local now = clock()
---@cast raw_data_buffer -nil
raw_data_buffer:putf('LUANTI;%s%s %d\n',
stack, vmstate_mapping[vmstate],
math.floor(scorer(now - time_point, samples)))
time_point = now
end
local duration_start ---@type number
local data_file ---@type string?
local open_data ---@type function
---@param period number interval between sampling in milliseconds
---@param fname string data file to update
---@param open function? io.open, for data file
function profiler_start(period, fname, open)
duration_start = clock()
data_file = resolve_path(norm_path(fname))
open_data = open or io.open
raw_data_buffer = strbuf.new()
scorer = jitprofiler.scorer or default_scorer
-- zones = {}
-- zones_hashmap = {}
-- zone_points = {}
-- jitprofiler.zone_start = zone_start
-- jitprofiler.zone_stop = zone_stop
jpstart("vfi" ..tostring(math.max(1, tonumber(period))), profiler_record)
time_point = clock()
end
function profiler_stop()
jpstop()
local data_size = #raw_data_buffer
local duration = (clock() - duration_start) / 1e3
---@cast raw_data_buffer -nil
---@cast data_file -nil
write_file(data_file, open_data, 'storing raw profiling data', raw_data_buffer)
-- zones = {}
-- zones_hashmap = {}
-- zone_points = {}
-- jitprofiler.zone_start = zone_noop
-- jitprofiler.zone_stop = zone_noop
data_file = nil
raw_data_buffer = nil
return duration, data_size
end
-- TODO decide what to report
---@param data_path string pretty data file to read
---@param open_data function? io.open, for pretty data file
---@param map_path string mapping file to write or update
---@param open_map function? io.open, for mapping file
function profiler_learn(data_path, map_path, open_data, open_map)
if raw_data_buffer then
error('attempt to learn profiler data while a profiler is running. not allowed')
end
local data_path = resolve_path(norm_path(data_path))
local map_path = resolve_path(norm_path(map_path))
local open_data = open_data or io.open
local open_map = open_map or io.open
local data = read_file(data_path, open_data, 'getting pretty profiling data')
:gsub(';@B', ';' ..SYSTEM_PATH ..'builtin')
:gsub(';@W', ';' ..WORLD_PATH ..'worldmods')
:gsub(';@G', ';' ..GAMES_PARENT ..GAME_MODS_PATH:sub(2))
:gsub(';@M', ';' ..MODS_PARENT ..'mods')
local mappings = {}
local rfile = open_map(map_path, 'r')
if rfile then
local mappings_raw = rfile:read('*a') ---@type string?
assert(mappings_raw, 'unable to read file getting existing frame mappings: ' ..map_path)
rfile:close()
local f = loadstring(mappings_raw
:gsub(';@B', ';' ..SYSTEM_PATH ..'builtin')
:gsub(';@W', ';' ..WORLD_PATH ..'worldmods')
:gsub(';@G', ';' ..GAMES_PARENT ..GAME_MODS_PATH:sub(2))
:gsub(';@M', ';' ..MODS_PARENT ..'mods'), map_path)
assert(f, 'unable to load file getting existing frame mappings' ..map_path)
setfenv(f, {})
mappings = f()
end
local mapping_keys = {}
local done_frame = {}
for entry in data:gmatch('([^\n]+)\n') do
local frames = entry:match('^LUANTI;(.*) %d+$'):split(';')
for f=1, #frames do
local frame = frames[f]
if done_frame[frame] then goto continue end
done_frame[frame] = true
---@type string?, integer?
local module_path, lnnum = frame:match('^(.+%.lua):(%d+)$')
lnnum = tonumber(lnnum)
if not module_path or not lnnum then goto continue end
local module_raw = read_file(module_path, danger_open, 'getting frame info')
local start = 1
for c=1, lnnum-1 do -- consume all the previous lines
local nx = module_raw:find('\n', start, true)
if not nx and c < lnnum-1 then
core.log('warning', 'EOF. potential change outside of Luanti. Unable to find the line in this frame: ' ..frame)
goto continue
end
start = nx+1
end
local line = module_raw:sub(start, module_raw:find('\n', start, true) - 1)
---@type string?
local fn_name = line:match('function%s+([A-Za-z_][A-Za-z0-9_.:]*)%s*%(')
if not fn_name then
fn_name = line:match('([A-Za-z_][A-Za-z0-9_.:]*)%s*=%s*function%s*%(')
end
if not fn_name then
local registrar = line:match('([A-Za-z0-9_.:]*register_[A-Za-z0-9_.:]*)%s*%(.*function%s*%(')
if registrar then fn_name = ('%s HOOK'):format(registrar) end
end
if not fn_name then
core.log('info', 'failed to find function in this frame: ' ..frame)
goto continue
end
local key = ';' ..frame
local value = (';%s:%d (%s)'):format(module_path, lnnum, fn_name)
mappings[key] = value
mapping_keys[#mapping_keys+1] = key
::continue::
end
end
---@diagnostic disable-next-line: cast-local-type
data = nil
table.sort(mapping_keys)
local mapping_databuf = strbuf.new():set('return {\n')
for i=1, #mapping_keys do
mapping_databuf:put((' [%q] = %q,\n'):format(mapping_keys[i], mappings[mapping_keys[i]]))
end
mapping_databuf:put('}\n')
write_file(map_path, open_map, 'storing frame mappings', mapping_databuf:tostring()
:gsub(WORLD_PATH_PAT, ';')
:gsub(GAMES_PARENT_PAT, ';')
:gsub(MODS_PARENT_PAT, ';')
:gsub(SYSTEM_PATH_PAT, ';')
:gsub(';builtin', ';@B')
:gsub(';worldmods', ';@W')
:gsub(GAME_MODS_PATH, ';@G')
:gsub(';mods', ';@M'))
return mappings
end
end
---@param data_path string data file to prettify
---@param open_data function? io.open, for data file
local function profiler_pretty(data_path, open_data)
if raw_data_buffer then
error('attempt to pretty profiler data while a profiler is running. not allowed')
end
local data_path = resolve_path(norm_path(data_path))
local open_data = open_data or io.open
-- norm_path normalizes potential weird paths
local data = norm_path(read_file(data_path, open_data, 'getting raw profiling data'))
:gsub(WORLD_PATH_PAT, ';')
:gsub(GAMES_PARENT_PAT, ';')
:gsub(MODS_PARENT_PAT, ';')
:gsub(SYSTEM_PATH_PAT, ';')
:gsub(';builtin', ';@B')
:gsub(';worldmods', ';@W')
:gsub(GAME_MODS_PATH, ';@G')
:gsub(';mods', ';@M')
if next(frame_map) then data = data:gsub(';[^;\n]+\n', frame_map) end
for f=1, #stack_filter do
data = data:gsub(stack_filter[f], '')
end
if jitprofiler.trim_zeroes then
data = data:gsub('LUANTI;[^\n]+ 0\n', '')
end
local data_size = #data
write_file(data_path, open_data, 'storing pretty profiling data', data)
return data_size
end
---@param data_path string pretty data file to read and update
---@param open_data function? io.open, for pretty data file
---@param map_path string mapping file to read
---@param open_map function? io.open, for mapping file
local function profiler_remap(data_path, map_path, open_data, open_map)
if raw_data_buffer then
error('attempt to pretty profiler data while a profiler is running. not allowed')
end
local data_path = resolve_path(norm_path(data_path))
local map_path = resolve_path(norm_path(map_path))
local open_data = open_data or io.open
local open_map = open_map or io.open
local f = loadstring(read_file(map_path, open_map, 'getting frame mapping'), map_path)
assert(f, 'unable to load file getting existing frame mappings' ..map_path)
setfenv(f, {})
local mappings = f()
for k, v in pairs(jitprofiler.frame_map) do
mappings[k] = v
end
local data = read_file(data_path, open_data, 'getting pretty profiler data')
write_file(data_path, open_data, 'storing pretty profiler data', data
:gsub(';[^;]+', mappings))
end
local cmd_period = STORAGE:get('last_period')
local cmd_data_fname = STORAGE:get('last_data_fname')
local cmd_data_path
core.register_chatcommand("jp_start", {
description = "Start profiler",
params = "<period> <data filename>",
privs = {server = true},
---@param args string
func = function(_, args)
if raw_data_buffer then
return false, "Profiler already running"
end
local args = args:trim()
if args:find('[/\\]') then return false, 'bad characters "/\\"' end
---@type string?, string?
local period, fname = args:match("^(%d+)%s+(.*)$")
if not period and not fname then fname = args end
local period_num = tonumber(period)
if period and not period_num then
return false, 'bad argument to <period>, expecting integer got ' ..period
end
if not period_num and not cmd_period then
return false, 'required <period> argument as no profiling was conducted previously'
end
if fname == '' then fname = nil end
if not fname and not cmd_data_fname then
return false, 'required <data filename> argument as no profiling was conducted previously'
end
cmd_period = period_num and math.floor(period_num) or cmd_period
cmd_data_fname = fname or cmd_data_fname
STORAGE:set_int('last_period', cmd_period)
STORAGE:set_string('last_data_fname', cmd_data_fname)
cmd_data_path = DATA_DIR .. "/" .. cmd_data_fname
profiler_start(cmd_period, cmd_data_path)
return true, "Profiler started. Writing data to " .. cmd_data_path
end,
})
core.register_chatcommand("jp_stop", {
description = "Stop profiler",
privs = {server = true},
func = function()
if not raw_data_buffer then
return false, "Profiler not running"
end
local duration, raw_size = profiler_stop()
local t0 = ms_clock()
local pretty_size = profiler_pretty(cmd_data_path)
return true, ('Profiler stopped after %s. Raw data size was %s. Took %s to prettify data into %s'):format(
pretty_time(duration), pretty_mem(raw_size),
pretty_time(ms_clock() - t0), pretty_mem(pretty_size))
end,
})
---@param args string
local function data_mapping_params(args)
local comma = args:find(',', nil, true)
if not comma then return false, 'missing comma separating <data filename> and <mapping filename>' end
local data_fname = args:sub(1, comma-1):trim()
local map_fname = args:sub(comma+1):trim()
return DATA_DIR ..'/' ..data_fname, MAPPINGS_DIR ..'/' ..map_fname ..'.lua'
end
core.register_chatcommand('jp_learn', {
description = 'Produce frame mapping from profiled data',
params = "<data filename>, <mapping name>",
privs = {server = true},
---@param args string
func = function(_, args)
if raw_data_buffer then
return false, "Profiler is running. Not allowed"
end
local args = args:trim()
if args == '' then return false, 'missing arguments' end
if args:find('[/\\]') then return false, 'bad characters "/\\"' end
local data_path, map_path = data_mapping_params(args)
if not data_path then return false, map_path end
local t0 = ms_clock()
profiler_learn(data_path, map_path) -- TODO report more things
return true, ('Learnt new frame mappings in %s'):format(
pretty_time(ms_clock() - t0))
end
})
core.register_chatcommand('jp_remap', {
description = 'Apply frame mapping to profiled data',
params = "<data filename>, <mapping name>",
privs = {server = true},
---@param args string
func = function(_, args)
if raw_data_buffer then
return false, "Profiler is running. Not allowed"
end
local args = args:trim()
if args == '' then return false, 'missing arguments' end
if args:find('[/\\]') then return false, 'bad characters "/\\"' end
local data_path, map_path = data_mapping_params(args)
if not data_path then return false, map_path end
local t0 = ms_clock()
profiler_remap(data_path, map_path) -- TODO report more things
return true, ('Remapped frames in %s'):format(
pretty_time(ms_clock() - t0))
end
})
jitprofiler = {
start = profiler_start,
stop = profiler_stop,
pretty = profiler_pretty,
learn = profiler_learn,
remap = profiler_remap,
scorer = nil,
-- when the profiler is on, it pushes a new zone in between the stacks
-- zone_start = zone_noop,
-- when the profiler is on, it pops a zone in between the stacks
-- zone_stop = zone_noop,
frame_map = frame_map,
stack_filter = stack_filter,
-- trim away stack dumps with 0 score
trim_zeroes = true,
}