Merge branch 'gc-upgrade' into compiler-refactor

This commit is contained in:
Mattia Giambirtone 2023-02-25 17:37:32 +01:00
commit 47ac1be6aa
3 changed files with 86 additions and 72 deletions

View File

@ -71,7 +71,6 @@ type
cycles: int cycles: int
nextGC: int nextGC: int
pointers: HashSet[uint64] pointers: HashSet[uint64]
objects: seq[ptr HeapObject]
PeonVM* = object PeonVM* = object
## The Peon Virtual Machine. ## The Peon Virtual Machine.
## Note how the only data ## Note how the only data
@ -105,7 +104,6 @@ proc newPeonGC*: PeonGC =
## Initializes a new, blank ## Initializes a new, blank
## garbage collector ## garbage collector
result.bytesAllocated = (0, 0) result.bytesAllocated = (0, 0)
result.objects = @[]
result.nextGC = FirstGC result.nextGC = FirstGC
result.cycles = 0 result.cycles = 0
@ -113,9 +111,19 @@ proc newPeonGC*: PeonGC =
proc collect*(self: var PeonVM) proc collect*(self: var PeonVM)
# Our pointer tagging routines
template tag(p: untyped): untyped = cast[pointer](cast[uint64](p) or (1'u64 shl 63'u64))
template untag(p: untyped): untyped = cast[pointer](cast[uint64](p) and 0x7fffffffffffffff'u64)
template getTag(p: untyped): untyped = (p and (1'u64 shl 63'u64)) == 0
proc reallocate*(self: var PeonVM, p: pointer, oldSize: int, newSize: int): pointer = proc reallocate*(self: var PeonVM, p: pointer, oldSize: int, newSize: int): pointer =
## Simple wrapper around realloc with ## Simple wrapper around realloc with
## built-in garbage collection ## built-in garbage collection. Callers
## should keep in mind that the returned
## pointer is tagged (bit 63 is set to 1)
## and should be passed to untag() before
## being dereferenced or otherwise used
self.gc.bytesAllocated.current += newSize - oldSize self.gc.bytesAllocated.current += newSize - oldSize
try: try:
when debugMem: when debugMem:
@ -137,9 +145,9 @@ proc reallocate*(self: var PeonVM, p: pointer, oldSize: int, newSize: int): poin
when debugStressGC: when debugStressGC:
self.collect() self.collect()
else: else:
if self.gc.bytesAllocated.current > self.gc.nextGC: if self.gc.bytesAllocated.current >= self.gc.nextGC:
self.collect() self.collect()
result = realloc(p, newSize) result = tag(realloc(untag(p), newSize))
except NilAccessDefect: except NilAccessDefect:
stderr.writeLine("Peon: could not manage memory, segmentation fault") stderr.writeLine("Peon: could not manage memory, segmentation fault")
quit(139) # For now, there's not much we can do if we can't get the memory we need, so we exit quit(139) # For now, there's not much we can do if we can't get the memory we need, so we exit
@ -167,17 +175,18 @@ template setKind[T, K](t: var T, kind: untyped, target: K) =
proc allocate(self: var PeonVM, kind: ObjectKind, size: typedesc, count: int): ptr HeapObject {.inline.} = proc allocate(self: var PeonVM, kind: ObjectKind, size: typedesc, count: int): ptr HeapObject {.inline.} =
## Allocates an object on the heap ## Allocates an object on the heap and adds its
result = cast[ptr HeapObject](self.reallocate(nil, 0, sizeof(HeapObject))) ## location to the internal pointer list of the
## garbage collector
result = cast[ptr HeapObject](untag(self.reallocate(nil, 0, sizeof(HeapObject))))
setkind(result[], kind, kind) setkind(result[], kind, kind)
result.marked = false result.marked = false
case kind: case kind:
of String: of String:
result.str = cast[ptr UncheckedArray[char]](self.reallocate(nil, 0, sizeof(size) * count)) result.str = cast[ptr UncheckedArray[char]](untag(self.reallocate(nil, 0, sizeof(size) * count)))
result.len = count result.len = count
else: else:
discard # TODO discard # TODO
self.gc.objects.add(result)
self.gc.pointers.incl(cast[uint64](result)) self.gc.pointers.incl(cast[uint64](result))
when debugAlloc: when debugAlloc:
echo &"DEBUG - GC: Allocated new object: {result[]}" echo &"DEBUG - GC: Allocated new object: {result[]}"
@ -195,54 +204,50 @@ proc mark(self: ptr HeapObject): bool =
return true return true
proc markRoots(self: var PeonVM): seq[ptr HeapObject] = proc markRoots(self: var PeonVM): HashSet[ptr HeapObject] =
## Marks root objects *not* to be ## Marks root objects *not* to be
## collected by the GC and returns ## collected by the GC and returns
## their addresses ## their addresses
when debugGC: when debugGC:
echo "DEBUG - GC: Starting mark phase" echo "DEBUG - GC: Starting mark phase"
# Unlike what bob does in his book, # Unlike what Bob does in his book, we keep track
# we keep track of objects in a different # of objects another way, mainly due to the difference
# way due to the difference of our design. # of our respective designs. Specifically, our VM only
# Specifically, we don't have neat structs for # handles a single type (uint64), while Lox has a stack
# all peon objects: When we allocate() an object, # of heap-allocated structs (which is convenient, but slow).
# we keep track of the small wrapper it created # The previous implementation would just store all pointers
# along with its type and other metadata. Then, # allocated by us in a hash set and then check if any source
# we can go through the various sources of roots # of roots contained any of the integer values that it was
# in the VM, see if they match any pointers we # keeping track of, but this meant that if a primitive object's
# already know about (we store them in a hash set so # value happened to collide with an active pointer the GC would
# it's really fast), and then we can be sure that # mistakenly assume the object was reachable, potentially leading
# anything that's in the difference (i.e. mathematical # to a nasty memory leak. The current implementation uses pointer
# set difference) between our full list of pointers # tagging: we know that modern CPUs never use bit 63 in addresses,
# and the live ones is not a root object, so if it's # so if it set we know it cannot be a pointer, and if it is set we
# not indirectly reachable through a root itself, it # just need to check if it's in our list of active addresses or not.
# can be freed. I'm not sure if I can call this GC # This should resolve the potential memory leak (hopefully)
# strategy precise, since technically there is a chance var result = initHashSet[uint64](self.gc.pointers.len())
# for a regular value to collide with one of the pointers
# we allocated and that would cause a memory leak, but
# with a 64-bit address-space it probably hardly matters,
# so I guess this is a mostly-precise Mark&Sweep collector
var live = initHashSet[uint64](self.gc.pointers.len())
for obj in self.calls: for obj in self.calls:
if not obj.getTag():
continue
if obj in self.gc.pointers: if obj in self.gc.pointers:
live.incl(obj) result.incl(obj)
for obj in self.operands: for obj in self.operands:
if not obj.getTag():
continue
if obj in self.gc.pointers: if obj in self.gc.pointers:
live.incl(obj) result.incl(obj)
# We preallocate the space on the seq
result = newSeqOfCap[ptr HeapObject](len(live))
var obj: ptr HeapObject var obj: ptr HeapObject
for p in live: for p in result:
obj = cast[ptr HeapObject](p) obj = cast[ptr HeapObject](p)
if obj.mark(): if obj.mark():
result.add(obj) when debugMarkGC:
when debugGC:
echo &"DEBUG - GC: Marked object: {obj[]}" echo &"DEBUG - GC: Marked object: {obj[]}"
when debugGC: when debugGC:
echo "DEBUG - GC: Mark phase complete" echo "DEBUG - GC: Mark phase complete"
proc trace(self: var PeonVM, roots: seq[ptr HeapObject]) = proc trace(self: var PeonVM, roots: HashSet[ptr HeapObject]) =
## Traces references to other ## Traces references to other
## objects starting from the ## objects starting from the
## roots. The second argument ## roots. The second argument
@ -252,7 +257,8 @@ proc trace(self: var PeonVM, roots: seq[ptr HeapObject]) =
## this is where we blacken gray ## this is where we blacken gray
## objects ## objects
when debugGC: when debugGC:
echo &"DEBUG - GC: Tracing indirect references from {len(roots)} roots" if len(roots) > 0:
echo &"DEBUG - GC: Tracing indirect references from {len(roots)} root{(if len(roots) > 1: \"s\" else: \"\")}"
var count = 0 var count = 0
for root in roots: for root in roots:
case root.kind: case root.kind:
@ -261,13 +267,16 @@ proc trace(self: var PeonVM, roots: seq[ptr HeapObject]) =
else: else:
discard # TODO: Other types discard # TODO: Other types
when debugGC: when debugGC:
echo &"DEBUG - GC: Traced {count} indirect references" echo &"DEBUG - GC: Traced {count} indirect reference{(if count != 1: \"s\" else: \"\")}"
proc free(self: var PeonVM, obj: ptr HeapObject) = proc free(self: var PeonVM, obj: ptr HeapObject) =
## Frees a single heap-allocated ## Frees a single heap-allocated
## peon object and all the memory ## peon object and all the memory
## it directly or indirectly owns ## it directly or indirectly owns. Note
## that the pointer itself is not released
## from the GC's internal table and must be
## handled by the caller
when debugAlloc: when debugAlloc:
echo &"DEBUG - GC: Freeing object: {obj[]}" echo &"DEBUG - GC: Freeing object: {obj[]}"
case obj.kind: case obj.kind:
@ -279,7 +288,6 @@ proc free(self: var PeonVM, obj: ptr HeapObject) =
else: else:
discard # TODO discard # TODO
self.free(HeapObject, obj) self.free(HeapObject, obj)
self.gc.pointers.excl(cast[uint64](obj))
when debugAlloc: when debugAlloc:
echo &"DEBUG - GC: Current heap size: {self.gc.bytesAllocated.current}" echo &"DEBUG - GC: Current heap size: {self.gc.bytesAllocated.current}"
echo &"DEBUG - GC: Total bytes allocated: {self.gc.bytesAllocated.total}" echo &"DEBUG - GC: Total bytes allocated: {self.gc.bytesAllocated.total}"
@ -291,37 +299,32 @@ proc sweep(self: var PeonVM) =
## Sweeps unmarked objects ## Sweeps unmarked objects
## that have been left behind ## that have been left behind
## during the mark phase. ## during the mark phase.
## This is more convoluted
## than it needs to be because
## nim disallows changing the
## size of a sequence during
## iteration
when debugGC: when debugGC:
echo "DEBUG - GC: Beginning sweeping phase" echo "DEBUG - GC: Beginning sweeping phase"
var j = -1
var idx = 0
when debugGC: when debugGC:
var count = 0 var count = 0
while j < self.gc.objects.high(): var current: ptr HeapObject
inc(j) var freed: HashSet[uint64]
if self.gc.objects[j].marked: for p in self.gc.pointers:
current = cast[ptr HeapObject](p)
if current.marked:
# Object is marked: don't touch it, # Object is marked: don't touch it,
# but reset its mark so that it doesn't # but reset its mark so that it doesn't
# stay alive forever # stay alive forever
when debugGC: when debugMarkGC:
echo &"DEBUG - GC: Unmarking object: {self.gc.objects[j][]}" echo &"DEBUG - GC: Unmarking object: {current[]}"
self.gc.objects[j].marked = false current.marked = false
inc(idx)
else: else:
# Object is unmarked: its memory is # Object is unmarked: its memory is
# fair game # fair game
self.free(self.gc.objects[idx]) self.free(current)
self.gc.objects.delete(idx) freed.incl(p)
inc(idx)
when debugGC: when debugGC:
inc(count) inc(count)
# Set difference
self.gc.pointers = self.gc.pointers - freed
when debugGC: when debugGC:
echo &"DEBUG - GC: Swept {count} objects" echo &"DEBUG - GC: Swept {count} object{(if count > 1: \"s\" else: \"\")}"
proc collect(self: var PeonVM) = proc collect(self: var PeonVM) =
@ -331,6 +334,7 @@ proc collect(self: var PeonVM) =
when debugGC: when debugGC:
let before = self.gc.bytesAllocated.current let before = self.gc.bytesAllocated.current
let time = getMonoTime().ticks().float() / 1_000_000 let time = getMonoTime().ticks().float() / 1_000_000
echo ""
echo &"DEBUG - GC: Starting collection cycle at heap size {self.gc.bytesAllocated.current}" echo &"DEBUG - GC: Starting collection cycle at heap size {self.gc.bytesAllocated.current}"
echo &"DEBUG - GC: Total bytes allocated: {self.gc.bytesAllocated.total}" echo &"DEBUG - GC: Total bytes allocated: {self.gc.bytesAllocated.total}"
echo &"DEBUG - GC: Tracked objects: {self.gc.pointers.len()}" echo &"DEBUG - GC: Tracked objects: {self.gc.pointers.len()}"
@ -339,6 +343,8 @@ proc collect(self: var PeonVM) =
self.trace(self.markRoots()) self.trace(self.markRoots())
self.sweep() self.sweep()
self.gc.nextGC = self.gc.bytesAllocated.current * HeapGrowFactor self.gc.nextGC = self.gc.bytesAllocated.current * HeapGrowFactor
if self.gc.nextGC == 0:
self.gc.nextGC = FirstGC
when debugGC: when debugGC:
echo &"DEBUG - GC: Collection cycle has terminated in {getMonoTime().ticks().float() / 1_000_000 - time:.2f} ms, collected {before - self.gc.bytesAllocated.current} bytes of memory in total" echo &"DEBUG - GC: Collection cycle has terminated in {getMonoTime().ticks().float() / 1_000_000 - time:.2f} ms, collected {before - self.gc.bytesAllocated.current} bytes of memory in total"
echo &"DEBUG - GC: Next cycle at {self.gc.nextGC} bytes" echo &"DEBUG - GC: Next cycle at {self.gc.nextGC} bytes"

View File

@ -17,13 +17,14 @@ import strformat
# These variables can be tweaked to debug and test various components of the toolchain # These variables can be tweaked to debug and test various components of the toolchain
const debugLexer* {.booldefine.} = false # Print the tokenizer's output const debugLexer* {.booldefine.} = false # Print the tokenizer's output
const debugParser* {.booldefine.} = false # Print the AST generated by the parser const debugParser* {.booldefine.} = false # Print the AST generated by the parser
const debugCompiler* {.booldefine.} = false # Disassemble and /or print the code generated by the compiler const debugCompiler* {.booldefine.} = false # Disassemble and/or print the code generated by the compiler
const debugVM* {.booldefine.} = false # Enable the runtime debugger in the bytecode VM const debugVM* {.booldefine.} = false # Enable the runtime debugger in the bytecode VM
const debugGC* {.booldefine.} = false # Debug the Garbage Collector (extremely verbose) const debugGC* {.booldefine.} = false # Debug the Garbage Collector (extremely verbose)
const debugAlloc* {.booldefine.} = false # Trace object allocation (extremely verbose) const debugAlloc* {.booldefine.} = false # Trace object allocation (extremely verbose)
const debugMem* {.booldefine.} = false # Debug the memory allocator (extremely verbose) const debugMem* {.booldefine.} = false # Debug the memory allocator (extremely verbose)
const debugSerializer* {.booldefine.} = false # Validate the bytecode serializer's output const debugSerializer* {.booldefine.} = false # Validate the bytecode serializer's output
const debugStressGC* {.booldefine.} = false # Make the GC run a collection at every allocation (VERY SLOW!) const debugStressGC* {.booldefine.} = false # Make the GC run a collection at every allocation (VERY SLOW!)
const debugMarkGC* {.booldefine.} = false # Trace the marking phase object by object (extremely verbose)
const PeonBytecodeMarker* = "PEON_BYTECODE" # Magic value at the beginning of bytecode files const PeonBytecodeMarker* = "PEON_BYTECODE" # Magic value at the beginning of bytecode files
const HeapGrowFactor* = 2 # The growth factor used by the GC to schedule the next collection const HeapGrowFactor* = 2 # The growth factor used by the GC to schedule the next collection
const FirstGC* = 1024 * 1024; # How many bytes to allocate before running the first GC const FirstGC* = 1024 * 1024; # How many bytes to allocate before running the first GC

View File

@ -1,17 +1,24 @@
import std; import std;
var x = 10000000; const max = 50000;
var y = "just a test";
print(y); var x = max;
var s = "just a test";
print(s);
print("Starting GC torture test"); print("Starting GC torture test");
print(x); print(x);
while x > 0 { while x > 0 {
"hello"; "1";
x = x - 1; x = x - 1;
} }
print("END"); x = max;
print(y); print(x);
y = "test"; while x > 0 {
print(y); "1";
""; x = x - 1;
}
print(s);
s = "test";
print(s);
"end of the world";