The Oforth Programming Language

Oforth Memory Management and garbage collector

I - Memory management

Here is a running Oforth system and where memory is allocated :

An Oforth system contains :

Dictionary : it is a memory area when all words are stored. It also contains all static objects declared into words (constant strings, floats, ...). All tasks have access to the dictionary. It only contains immutable objects and is not concerned by the garbage collector phase.

Workers : they are OS threads that run tasks. When a task is stopping (because the task is finished or enter into wait state, the worker retrieves a new task to run into the resumable tasks list. If this list is empty, it goes into sleep until a task is ready to run. A worker has its own dedicated heap and manages it.

Tasks : they are small objects performed by workers. When a task is waiting for a resource (a channel, a socket, ...), it enters into WAIT state. A task has its own dedicated data stack. When a running task allocates an object, it asks to its worker to allocate it on the worker heap.

Scheduler : It is a dedicated OS thread. The scheduler is the heart of an oforth system : it creates new workers (if needed), it detects events and send tasks waiting for those events to the resumable tasks list, it awakes sleeping workers when the resumable tasks list is not empty, and it raises GC events. The scheduler itself is not responsible to run GC. GC is performed by the workers.

At start up, there is one task (the interpreter) created and one worker. When the interpreter has finished to load the "lang" package, it will wait for input from the console. So the Oforth system is :

When a key is pressed, the scheduler detects it, send the interpreter task to the resumable tasks list and wake up the worker. The worker retrieves this task and resume it until the interpreter enter again into WAIT state.

II - Garbage collector

Each 120 milliseconds (or --WTn command line option parameter if set), the scheduler sends a GC event to workers. There are 2 kinds of garbages :

Restrict garbage : the garbage collector will only checks for mutable objects. Living immutable objects are considered as used.

Global garbage : the garbage collector will check all objects. It occurs after 9 restrict garbage collectors (so every 1200 milliseconds by default).

Since V0.9.22 release, Oforth runs an incremental mark and sweep garbage collector (GC) : the GC is no more a "stop the world" phase and tasks are allowed to run between GC steps until the GC is finished. Objective is to allow tasks to run at least every 100 microseconds during GC and to not restart GC steps before task have accomplished some amount of work.

From the garbage collector point of view :

Used objects are on tasks stacks (data stack or return stack).

Each worker is handling its own heap, and this heap must release objects that have been allocated by tasks and no more used.

So, the strategy is :

All workers that are currently running a task stop it.

During the mark phase, the workers retrieve all waiting tasks and mark used objects.

During the sweep phase, each worker checks its heap and free all objects that are not marked.

Workers are doing all this step by step in order to allow tasks to run between those steps.

Here are the GC steps for one worker :

III - Mark phase

The mark phase is divided into steps :

The first step is a snapshot. It is a "stop the world" step. During this step, all tasks are scanned by workers and, for each task retrieved by a worker, all objects (but not objects attributes) that are present on the data stack and the return stack are stored into the worker Mark Stack. The mark stack is a stack dedicated to a worker that contains all the objects it must mark during the mark phase. This step can't be stopped and no task can run during this step.

Then, each worker begins to handle its mark stack until it is empty : an object is removed for the mark stack and is tagged as USED. All object attributes are pushed on the mark stack and the worker loops on its mark stack. When a number of objects has been handled and the Mark Stack is not empty, the worker stops its mark step and run resumable tasks. When tasks have used an amout of CPU, the worker go back to handle its mark stack again.

When all workers have processed their mark stack, they are synchronised to begin the sweep phase. This is also a "stop the world" step to be sure that all objects are marked before the sweep phase begin. After this point, workers are no more synchronised.

When a task is running during a mark phase, some adjustments must be done to not disturb GC : all objects created are created as USED by the worker and, if an object attribute is changed, the old value of the attribute is sent into current worker mark stack to be sure that it will be marked.

IV - Sweep phase

The sweep phase is also divided into steps where each worker handle its own heap.

It sets back to NOT USED objects marked as USED during the mark phase and free objects that were not tagged during the mark phase. When a number of objects has been handled and there is still heap to handle, the worker stops its sweep step and run resumable tasks. When tasks have used an amount of CPU, the worker go back to sweep its remaining heap.

When a task is running during a sweep phase, the only adjustement is on objects creation : they will continue to be created as USED until the memory pages used for allocation are sweeped. When sweeped, objects can now be created as NOT USED as if the GC was not running.

V - Current restrictions and possible optimizations

Dividing the GC phase into steps of duration of about 100 microseconds allows tasks to run between those steps : the GC is not more a "stop the world" phase. But, into current release, there are some situations where steps of 100 microseconds are not guaranted. Next releases will try to resolve those situations.

Snapshot step can't be stopped to run tasks. There is no guarantee that it will last less than 100 microseconds.

Lists must be entirely marked during a step and this can't be stopped.

Mark stacks have a limited size : when the mark stack is full, next objects are directly marked until the mark stack is no more full.

Operations on integers must finish before the task give CPU back to GC : with huge integers, this can lead to block the GC until the operation is done.

Operations on sockets have to be revisited : even if sockets are non blocking, if a task is awaken because there is someting to do on a socket, currently, the operation itself can't be stopped.

There are also some optimizations to check, too :

Perhap's the mark phase is too conservative when creating new objects : they are all marked as used.

Workers that have finished their mark phase could help others that have more work to do. But this would need another synchronisation between workers.

VI - GC parameters

Some GC parameters can be defined on oforth command line. Those parameters are :

--XAn : "Amount" of CPU for tasks between GC steps.

This parameter allows to adjust how long tasks will run during GC steps. This value is a number of ticks. Ticks are taken on various points when a task is running. Default value is 300 and is adjusted to run tasks during 15 micro seconds between each GC step (on a Core i5). Increasing this value will allow tasks to run longer between GC step. As a drawback, GC will run longer and memory used will be greater.

--XGn : Amount of CPU for a GC step.

This parameter allows to adjust how long a gc step will run before giving back CPU to tasks. Default value is 6000 and is adjusted to run a GC step in about 100 micro seconds (on a Core i5). Decreasing this value will allow GC steps to take less time. As a drawback, GC will run longer (more steps will be needed to run the GC) and memory used will be greater.

--XTn : Number of milliseconds between 2 GC

This parameter defines number of milliseconds between 2 GC. Default value is 120 milliseconds. Increasing the value give more time to tasks, but will increase memory used.

--XMn : Memory (Kb) to reach before GC runs.

Default value is 1024 Kb. If memory allocated is less than this value, GC will never run. If memory allocated is greater than this value, GC will run (every 120 ms or --XTn parameter).

When verbose level is set, GC output is (all ticks are microseconds) :

Level 1 : GC_EVENT X:n : New event GC of type X raised by the scheduler at system tick n. X is R for restrict GC and G for global GC.

Level 2 : GB:w(n) : GC begins for worker w at tick n

Level 2 : SB:w(n) : Snapshot begins for worker w

Level 2 : SW:w(n) : Worker w has ended its snapshot

Level 2 : SE:w(n) : Snapshot step is finished (by worker w).

Level 2 : MB:w(n) : Mark phase begins for worker w

Level 3 : MS:w(n) : Mark step begins for worker w

Level 3 : MT:w(n) : Mark step ends for worker w

Level 2 : ME:w(n) : Mark phase ends for worker w

Level 2 : WB:w(n) : Sweep phase begins (by worker w)

Level 3 : WS:w(n) : Sweep step begins for worker w

Level 3 : WO:w(n) : Sweep step (object sweep) ends for worker w

Level 3 : WR:w(n) : Sweep step (raw memory sweep) ends for worker w

Level 2 : GE:w(n) : GC ends for worker w

Level 1 : GF:w(n) : GC ends for all worker (last worker is w)

VII - Some tests...

We are going to run some tests to see how this incremental GC works and, to do so, we must look how tasks run at microsecond level. Many things that were negligeable have now a big impact and will have impact on GC output :

OS scheduler : OS scheduler decides which threads will run. It can decide to run or not run a worker and this can have a very big influence. For instance, on Windows, the OS scheduler tick is about 16000 microseconds. So a worker can be seen as not running its GC phase during 16000 (or more). On Linux systems, OS scheduler tick is about 1000 microseconds so the impact is not so important (but not negligeable at all). There is not much to do about it : this is the way those OS work.

Console : at microsecond level, output on console is not negligeable at all. On windows for instance, it can be 400/500 microseconds. To see something, you should always redirect GC output to a file and not display it on a console.

Getting time : Even getting a tick from the system, calculate a duration and sending it to a file is no more a negligeable task (after all 1 microsecond is "just" 1000 nanoseconds). So you will notice ticks increasing by 1 just because GC itself is running in verbose mode.

Each (number) is the number of microseconds elapsed since GC event has been raised by the scheduler. But, we see huges numbers here (3 milliseconds for the full GC phase). This is because... we output on console. We must redirect output to a file to see something more accurate.

The worker detects the GC event quicker (7 microseconds) : this is because the interpreter task is now running and not waiting for console events.

The snapshot step is very fast (4 microseconds) : there is only one task and it keeps almost no objects on data stack or return stack.

The mark phase is very fast too (7 microseconds) : same reason.

For this reason, there are no differences between Restrict GC lines and Global GC lines : differences are important during the mark phase.

The sweep phase is long : 14500 microseconds ie 14 milliseconds : lots of unused objects have to be released during the sweep phase.

Each sweep step last about 60 microseconds : tests are done on a core i7, GC default parameters are tuned for a 80/100 microseconds by step on a Core i5.

Task between each GC sweep step run very quickly : 6 or 7 microseconds between GC steps. This is because, in this case, the task consumes its ticks very quickly (each object creation consumes one tick). Here, it could be interesting to increase the --XA parameter (300 ticks by default).

Oforth creates and free those 100 millions objects in about 1,3 seconds ie about 13 nanoseconds by object.

Let's say 1 millisecond GC steps duration is enough for you application and you would like to have the task running more between those steps, you can run oforth using those parameters (6000 x 10 for gc steps and 300 x 10 for tasks :

This test is a good test because it allocates a huge long-lived binary tree (depth 20) which will live-on while other trees are allocated and desallocated. Let's run this test with only 1 worker ( --W command line option) :

This test runs in about 8,5 seconds. On each line, you can see that, now, the 4 workers are participating to the GC phase. Not all of these are working during the mark phase (it depends if a worker is retrieving a task or not during the snapshot).

It is common to hear that, with a mark and sweep GC, the critical part is the sweep phase (check of all the heap to release objects, ...). Well, with this GC, it is not the case :

The sweep phase is the simpliest one.

Even under high object allocation rate, and multiple workers it will not last more that 15000 microseconds (each worker handle its heap).

Sweep steps duration are guaranted to be less than 100 microseconds (or the --XG parameter).

Here, the critical part is the mark phase :

Global mark duration can be very long (40 milliseconds) if lots of long-lived objects have to be marked.

At least with this version, there are restrictions on mark steps duration to last less than 100 microseconds (see chapter 5).

With this version, workers that have finished their mark stack don't helps others : they run tasks until the mark phase is finished. It would be great, in the last example, if the 3 workers helped the poor one that retrieved the task that hold the long lived tree during a global GC... This could be a great improvement (perhap's in a futur version) to limit mark stack duration.

During the mark phase, objects creations are conservatives : they are marked as USED even for short lived objects. So, if the mark phase is long, memory used will be larger.