remove some memory leaks from rendered fragments

All signs so far seem to be that CPU is cheap for this application, but
memory is expensive. It's easy to get sluggish if the GC comes on.

After some experiments using https://github.com/yaukeywang/LuaMemorySnapshotDump,
one source of memory leaks is rendered fragments (https://love2d.org/wiki/Text
objects). I need to render text in approximately word-sized fragments to
mostly break lines more intelligently at word boundaries.

I've attached the files I used for my experiments (suffixed with a '.')

There's definitely still a leak in fragments. The longer I edit, the
more memory goes to them.
This commit is contained in:
Kartik K. Agaram 2022-06-10 08:24:21 -07:00
parent fbad3dd205
commit 69c5d844cc
5 changed files with 2672 additions and 0 deletions

1075
MemoryReferenceInfo.lua. Normal file

File diff suppressed because it is too large Load Diff

1073
MemoryReferenceInfo.lua.0 Normal file

File diff suppressed because it is too large Load Diff

View File

@ -342,6 +342,7 @@ function App.keychord_pressed(chord)
Cursor1 = Search_backup.cursor
Screen_top1 = Search_backup.screen_top
Search_backup = nil
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
elseif chord == 'return' then
Search_term = nil
Search_text = nil
@ -380,6 +381,7 @@ function App.keychord_pressed(chord)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.after, event.before)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
elseif chord == 'C-y' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
@ -390,6 +392,7 @@ function App.keychord_pressed(chord)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.before, event.after)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
-- clipboard
elseif chord == 'C-c' then

517
main.lua. Normal file
View File

@ -0,0 +1,517 @@
local utf8 = require 'utf8'
require 'app'
require 'test'
require 'keychord'
require 'file'
require 'button'
local Text = require 'text'
local Drawing = require 'drawing'
local geom = require 'geom'
require 'help'
require 'icons'
local mri = require 'MemoryReferenceInfo'
-- run in both tests and a real run
function App.initialize_globals()
-- a line is either text or a drawing
-- a text is a table with:
-- mode = 'text',
-- string data,
-- a (y) coord in pixels (updated while painting screen),
-- some cached data that's blown away and recomputed when data changes:
-- fragments: snippets of rendered love.graphics.Text, guaranteed to not wrap
-- screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
-- a drawing is a table with:
-- mode = 'drawing'
-- a (y) coord in pixels (updated while painting screen),
-- a (h)eight,
-- an array of points, and
-- an array of shapes
-- a shape is a table containing:
-- a mode
-- an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing)
-- an array vertices for mode 'polygon', 'rectangle', 'square'
-- p1, p2 for mode 'line'
-- p1, p2, arrow-mode for mode 'arrow-line'
-- center, radius for mode 'circle'
-- center, radius, start_angle, end_angle for mode 'arc'
-- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide
-- The field names are carefully chosen so that switching modes in midstream
-- remembers previously entered points where that makes sense.
Lines = {{mode='text', data=''}}
-- Lines can be too long to fit on screen, in which case they _wrap_ into
-- multiple _screen lines_.
--
-- Therefore, any potential location for the cursor can be described in two ways:
-- * schema 1: As a combination of line index and position within a line (in utf8 codepoint units)
-- * schema 2: As a combination of line index, screen line index within the line, and a position within the screen line.
--
-- Most of the time we'll only persist positions in schema 1, translating to
-- schema 2 when that's convenient.
Screen_top1 = {line=1, pos=1} -- position of start of screen line at top of screen
Cursor1 = {line=1, pos=1} -- position of cursor
Screen_bottom1 = {line=1, pos=1} -- position of start of screen line at bottom of screen
Selection1 = {}
Old_cursor1, Old_selection1, Mousepress_shift = nil -- some extra state to compute selection between mousepress and mouserelease
Recent_mouse = {} -- when selecting text, avoid recomputing some state on every single frame
Cursor_x, Cursor_y = 0, 0 -- in pixels
Current_drawing_mode = 'line'
Previous_drawing_mode = nil
-- values for tests
Font_height = 14
Line_height = 15
Margin_top = 15
Filename = love.filesystem.getUserDirectory()..'/lines.txt'
-- undo
History = {}
Next_history = 1
-- search
Search_term = nil
Search_text = nil
Search_backup = nil -- stuff to restore when cancelling search
-- resize
Last_resize_time = nil
-- blinking cursor
Cursor_time = 0
Initialize_done = false
Before_done = false
mri.m_cConfig.m_bAllMemoryRefFileAddTime = false
end -- App.initialize_globals
function App.initialize(arg)
love.keyboard.setTextInput(true) -- bring up keyboard on touch screen
love.keyboard.setKeyRepeat(true)
if arg[1] == '-geometry' then
initialize_window_geometry(arg[2])
table.remove(arg, 2)
table.remove(arg, 1)
else
initialize_window_geometry()
end
initialize_font_settings(20)
if #arg > 0 then
Filename = arg[1]
end
print('init', collectgarbage('count'))
Lines = load_from_disk(Filename)
print('load_from_disk', collectgarbage('count'))
for i,line in ipairs(Lines) do
if line.mode == 'text' then
Cursor1.line = i
break
end
end
love.window.setTitle('lines.love - '..Filename)
if #arg > 1 then
print('ignoring commandline args after '..arg[1])
end
Initialize_done = true
--? if rawget(_G, 'jit') then
--? jit.off()
--? jit.flush()
--? end
end -- App.initialize
function initialize_window_geometry(geometry_spec)
local geometry_initialized
if geometry_spec then
geometry_initialized = parse_geometry_spec(geometry_spec)
end
if not geometry_initialized then
-- maximize window
love.window.setMode(0, 0) -- maximize
App.screen.width, App.screen.height, App.screen.flags = love.window.getMode()
-- shrink slightly to account for window decoration
App.screen.width = App.screen.width-100
App.screen.height = App.screen.height-100
end
App.screen.flags.resizable = true
App.screen.flags.minwidth = math.min(App.screen.width, 200)
App.screen.flags.minheight = math.min(App.screen.width, 200)
love.window.updateMode(App.screen.width, App.screen.height, App.screen.flags)
end
function parse_geometry_spec(geometry_spec)
local width, height, x, y = geometry_spec:match('(%d+)x(%d+)%+(%d+)%+(%d+)')
if width == nil then
print('invalid geometry spec: '..geometry_spec)
print('expected format: {width}x{height}+{x}+{y}')
return false
end
App.screen.width = math.floor(tonumber(width))
App.screen.height = math.floor(tonumber(height))
App.screen.flags = {x=math.floor(tonumber(x)), y=math.floor(tonumber(y))}
return true
end
function love.resize(w, h)
--? print(("Window resized to width: %d and height: %d."):format(w, h))
App.screen.width, App.screen.height = w, h
Line_width = math.min(40*App.width(Em), App.screen.width-50)
Text.redraw_all()
Last_resize_time = love.timer.getTime()
end
function initialize_font_settings(font_height)
Font_height = font_height
love.graphics.setFont(love.graphics.newFont(Font_height))
Line_height = math.floor(font_height*1.3)
-- maximum width available to either text or drawings, in pixels
Em = App.newText(love.graphics.getFont(), 'm')
-- readable text width is 50-75 chars
Line_width = math.min(40*App.width(Em), App.screen.width-50)
end
function App.filedropped(file)
App.initialize_globals() -- in particular, forget all undo history
Filename = file:getFilename()
file:open('r')
Lines = load_from_file(file)
file:close()
for i,line in ipairs(Lines) do
if line.mode == 'text' then
Cursor1.line = i
break
end
end
love.window.setTitle('Text with Lines - '..Filename)
end
frame_index = 0
function App.draw()
frame_index = frame_index+1
if frame_index % 10 == 0 then
print(frame_index)
end
Button_handlers = {}
love.graphics.setColor(1, 1, 1)
love.graphics.rectangle('fill', 0, 0, App.screen.width-1, App.screen.height-1)
love.graphics.setColor(0, 0, 0)
-- some hysteresis while resizing
if Last_resize_time then
if love.timer.getTime() - Last_resize_time < 0.1 then
return
else
Last_resize_time = nil
end
end
assert(Text.le1(Screen_top1, Cursor1))
local y = Margin_top
--? print('== draw')
for line_index,line in ipairs(Lines) do
--? print('draw:', y, line_index, line)
if y + Line_height > App.screen.height then break end
--? print('a')
if line_index >= Screen_top1.line then
Screen_bottom1.line = line_index
if line.mode == 'text' and line.data == '' then
line.y = y
button('draw', {x=4,y=y+4, w=12,h=12, color={1,1,0},
icon = icon.insert_drawing,
onpress1 = function()
Drawing.before = snapshot()
table.insert(Lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}})
if Cursor1.line >= line_index then
Cursor1.line = Cursor1.line+1
end
end})
if Search_term == nil then
if line_index == Cursor1.line then
Text.draw_cursor(25, y)
end
end
Screen_bottom1.pos = Screen_top1.pos
y = y + Line_height
elseif line.mode == 'drawing' then
y = y+10 -- padding
line.y = y
Drawing.draw(line)
y = y + Drawing.pixels(line.h) + 10 -- padding
else
--? print('text')
line.y = y
y, Screen_bottom1.pos = Text.draw(line, Line_width, line_index)
y = y + Line_height
--? print('=> y', y)
end
end
end
--? print('screen bottom: '..tostring(Screen_bottom1.pos)..' in '..tostring(Lines[Screen_bottom1.line].data))
if Search_term then
Text.draw_search_bar()
end
if Initialize_done then
if not Before_done then
Before_done = true
print('before', collectgarbage('count'))
collectgarbage('collect')
mri.m_cMethods.DumpMemorySnapshot('./', '0', -1)
frame_index = 0
elseif frame_index == 1000 then
print('after', collectgarbage('count'))
collectgarbage('collect')
mri.m_cMethods.DumpMemorySnapshot('./', '1', -1)
mri.m_cMethods.DumpMemorySnapshotComparedFile("./", "Compared", -1,
"./LuaMemRefInfo-All-[0].txt",
"./LuaMemRefInfo-All-[1].txt")
os.exit(1)
end
end
end
function App.update(dt)
Cursor_time = Cursor_time + dt
-- some hysteresis while resizing
if Last_resize_time then
if love.timer.getTime() - Last_resize_time < 0.1 then
return
else
Last_resize_time = nil
end
end
Drawing.update(dt)
end
function App.mousepressed(x,y, mouse_button)
if Search_term then return end
propagate_to_button_handlers(x,y, mouse_button)
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y) then
-- delicate dance between cursor, selection and old cursor
-- manual tests:
-- regular press+release: sets cursor, clears selection
-- shift press+release:
-- sets selection to old cursor if not set otherwise leaves it untouched
-- sets cursor
-- press and hold to start a selection: sets selection on press, cursor on release
-- press and hold, then press shift: ignore shift
-- i.e. mousereleased should never look at shift state
Old_cursor1 = Cursor1
Old_selection1 = Selection1
Mousepress_shift = App.shift_down()
Selection1 = {line=line_index, pos=Text.to_pos_on_line(line, x, y)}
end
elseif line.mode == 'drawing' then
if Drawing.in_drawing(line, x, y) then
Drawing.mouse_pressed(line, x,y, button)
end
end
end
end
function App.mousereleased(x,y, button)
if Search_term then return end
if Lines.current_drawing then
Drawing.mouse_released(x,y, button)
else
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y) then
Cursor1 = {line=line_index, pos=Text.to_pos_on_line(line, x, y)}
if Mousepress_shift then
if Old_selection1.line == nil then
Selection1 = Old_cursor1
else
Selection1 = Old_selection1
end
end
Old_cursor1, Old_selection1, Mousepress_shift = nil
end
end
end
--? print('select:', Selection1.line, Selection1.pos)
end
end
function App.textinput(t)
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
if Search_term then
Search_term = Search_term..t
Search_text = nil
Text.search_next()
elseif Current_drawing_mode == 'name' then
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
p.name = p.name..t
else
Text.textinput(t)
end
save_to_disk(Lines, Filename)
end
function App.keychord_pressed(chord)
if Search_term then
if chord == 'escape' then
Search_term = nil
Search_text = nil
Cursor1 = Search_backup.cursor
Screen_top1 = Search_backup.screen_top
Search_backup = nil
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
elseif chord == 'return' then
Search_term = nil
Search_text = nil
Search_backup = nil
elseif chord == 'backspace' then
local len = utf8.len(Search_term)
local byte_offset = utf8.offset(Search_term, len)
Search_term = string.sub(Search_term, 1, byte_offset-1)
Search_text = nil
elseif chord == 'down' then
Cursor1.pos = Cursor1.pos+1
Text.search_next()
elseif chord == 'up' then
Text.search_previous()
end
return
elseif chord == 'C-f' then
Search_term = ''
Search_backup = {cursor={line=Cursor1.line, pos=Cursor1.pos}, screen_top={line=Screen_top1.line, pos=Screen_top1.pos}}
assert(Search_text == nil)
elseif chord == 'C-=' then
initialize_font_settings(Font_height+2)
Text.redraw_all()
elseif chord == 'C--' then
initialize_font_settings(Font_height-2)
Text.redraw_all()
elseif chord == 'C-0' then
initialize_font_settings(20)
Text.redraw_all()
elseif chord == 'C-z' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = undo_event()
if event then
local src = event.before
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.after, event.before)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
elseif chord == 'C-y' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = redo_event()
if event then
local src = event.after
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.before, event.after)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
-- clipboard
elseif chord == 'C-c' then
print('C-c', collectgarbage('count'))
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.selection()
if s then
App.setClipboardText(s)
end
elseif chord == 'C-x' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.cut_selection()
if s then
App.setClipboardText(s)
end
save_to_disk(Lines, Filename)
elseif chord == 'C-v' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
-- We don't have a good sense of when to scroll, so we'll be conservative
-- and sometimes scroll when we didn't quite need to.
local before_line = Cursor1.line
local before = snapshot(before_line)
local clipboard_data = App.getClipboardText()
local num_newlines = 0 -- hack 1
for _,code in utf8.codes(clipboard_data) do
local c = utf8.char(code)
if c == '\n' then
Text.insert_return()
num_newlines = num_newlines+1
else
Text.insert_at_cursor(c)
end
end
-- hack 1: if we have too many newlines we definitely need to scroll
for i=before_line,Cursor1.line do
Lines[i].screen_line_starting_pos = nil
Text.populate_screen_line_starting_pos(i)
end
if Cursor1.line-Screen_top1.line+1 + num_newlines > App.screen.height/Line_height then
Text.snap_cursor_to_bottom_of_screen()
end
-- hack 2: if we have too much text wrapping we definitely need to scroll
local clipboard_text = App.newText(love.graphics.getFont(), clipboard_data)
local clipboard_width = App.width(clipboard_text)
--? print(Cursor_y, Cursor_y*Line_width, Cursor_y*Line_width+Cursor_x, Cursor_y*Line_width+Cursor_x+clipboard_width, Line_width*App.screen.height/Line_height)
if Cursor_y*Line_width+Cursor_x + clipboard_width > Line_width*App.screen.height/Line_height then
Text.snap_cursor_to_bottom_of_screen()
end
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot(before_line, Cursor1.line)})
-- dispatch to drawing or text
elseif love.mouse.isDown('1') or chord:sub(1,2) == 'C-' then
-- DON'T reset line.y here
Drawing.keychord_pressed(chord)
elseif chord == 'escape' and love.mouse.isDown('1') then
local drawing = Drawing.current_drawing()
if drawing then
drawing.pending = {}
end
elseif chord == 'escape' and not love.mouse.isDown('1') then
for _,line in ipairs(Lines) do
if line.mode == 'drawing' then
line.show_help = false
end
end
elseif Current_drawing_mode == 'name' then
if chord == 'return' then
Current_drawing_mode = Previous_drawing_mode
Previous_drawing_mode = nil
else
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
if chord == 'escape' then
p.name = nil
elseif chord == 'backspace' then
local len = utf8.len(p.name)
local byte_offset = utf8.offset(p.name, len-1)
p.name = string.sub(p.name, 1, byte_offset)
end
end
save_to_disk(Lines, Filename)
else
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
Text.keychord_pressed(chord)
end
end
function App.keyreleased(key, scancode)
end

View File

@ -223,6 +223,7 @@ function Text.keychord_pressed(chord)
local top2 = Text.to2(Screen_top1)
top2 = Text.previous_screen_line(top2)
Screen_top1 = Text.to1(top2)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
assert(Text.le1(Screen_top1, Cursor1))
save_to_disk(Lines, Filename)
@ -423,6 +424,7 @@ function Text.pagedown()
Cursor1.pos = Screen_top1.pos
Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary()
--? print('top now', Screen_top1.line)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
function Text.up()
@ -662,6 +664,7 @@ function Text.snap_cursor_to_bottom_of_screen()
--? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
Screen_top1 = Text.to1(top2)
--? print('top1 finally:', Screen_top1.line, Screen_top1.pos)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
end
function Text.in_line(line, x,y)
@ -895,6 +898,7 @@ function Text.populate_screen_line_starting_pos(line_index)
end
function Text.redraw_all()
print('clearing fragments')
for _,line in ipairs(Lines) do
line.y = nil
line.fragments = nil