Starting a Port: NoGC
We always start a port with NoGC. It is the simplest possible plan: it simply allocates memory and never collects. Although this appears trivial, depending on the complexity of the runtime and how well factored (or not) its internal GC interfaces are, just getting this working may be a major undertaking. In the case of V8, the refactoring within V8 required to get a simple NoGC plan working was substantial, touching over 100 files. So it’s a good idea not to underestimate the difficulty of a NoGC port!
At a high level, in order to implement NoGC, we need to handle MMTk initialization, mutator initialization, and memory allocation.
If you're ever stuck at any point, feel free to send a message in the #Porting
channel of our Zulip!
Set up
You want to set up the binding repository/directory structure before starting the port. For the sake of the tutorial guide we assume you have a directory structure similar to the one below. Note that such a directory structure is not a requirement1 but a recommendation. We assume you are using some form of version control system (such as git
or mercurial
) in this guide.
In fact some bindings may not be able to have such a directory structure due to the build tools used by the runtime.
mmtk-X/mmtk
: The MMTk side of the binding. This includes the implementation of theVMBinding
trait, and any necessary Rust code to integrate MMTk with the VM code (e.g. exposing MMTk functions to native, allowing up-calls from the MMTk binding to the runtime, etc). To start with, you can copy theDummyVM
code and start from there.DummyVM
provides all the Rust boilerplates that you need to implement in the binding side. You can also take a look at one of our officially maintained language bindings as an example: OpenJDK, JikesRVM, V8, Julia, V8.mmtk-X/X
: Runtime-specific code for integrating with MMTk. This should act as a bridge between the generic GC interface offered by the runtime and the MMTk side of the binding. This is implemented in the runtime's implementation language. Often this will be one of C or C++.- You can place your runtime repository at any path. For the sake of this guide, we assume you will place the runtime repo as a sibling of the binding repo. You can also clone
mmtk-core
to a local path. Using a local repo ofmmtk-core
can be beneficial to your development in case you need to make certain changes to the core (though this is unlikely).
Your working directory may look like this (assuming your runtime is named as X
):
Your working directory/
├─ mmtk-X/
│ ├─ X/
│ └─ mmtk/
├─ X/
└─ mmtk-core/ (optional)
You may also find it helpful to take inspiration from the OpenJDK binding, particularly for a more complete example of the relevant Cargo.toml
files.
For this guide, we will assume your runtime is implemented in C or C++ as they are the most common implementation languages. However note that your runtime does not need to be implemented in C/C++ to work with MMTk.
Adding a Rust library to the runtime
We recommend learning the ins and outs of your runtime's build system. You should try and add a simple Rust "hello world" library to your runtime's code and build system to investigate how easy it will be to add MMTk. Unfortunately this step is highly dependent on the runtime build system. We recommend taking a look at what other bindings do, but keep in mind that no two runtime build systems are the same even if they are using the same build tools.
In case the build system is too complex and you want get to hacking, a quick and dirty way to add MMTk could be to build a static and/or dynamic binary for MMTk and link it to the runtime directly, manually building new binaries as necessary, like so:
cd mmtk-X/mmtk
cargo build
to build in debug mode or add--release
for release mode- Copy the shared or static2 library from
target/debug
ortarget/release
to your desired location
You would have to change the crate-type
in mmtk-X/mmtk/Cargo.toml
from cdylib
to staticlib
to build a static library.
Later, you can edit the runtime build process to build MMTk at the same time automatically.
Note: If the runtime you are targeting already links some Rust FFI libraries, then you may notice "multiple definition" linker errors for Rust stdlib functions. Unfortunately this is a current limitation of Rust FFI wherein all symbols are bundled together in the final C lib which will cause multiple definitions errors when two or more Rust FFI libraries are linked together. There is ongoing work to stabilize the Rust package format that would hopefully make it easier in the future. A current workaround would be to use the -Wl,--allow-multiple-definition
linker flag, but this unfortunately isn't ideal as it increases code sizes. See here and here for more details.
Note: It is highly recommended to also check-in the generated Cargo.lock
file into your version control. This improves the reproducibility of the build and ensures the same package versions are used when building in the future in order to prevent random breakages.
We recommend using the debug
build when doing development work as it has helpful logging statements and assertions that will make catching bugs in your implementation easier.
The VMBinding
trait
Now let's actually start implementing the binding. Here we take a look at the Rust side of the binding first (i.e. mmtk-X/mmtk
). What we want to do is implement the VMBinding
trait.
The VMBinding
trait is a "meta-trait" (i.e. a trait that encapsulates other traits) that we expect every binding to implement. In essence, it is the contract established between MMTk and the runtime. We discuss each of its seven key traits briefly:
ActivePlan
: This trait implements functions related to mutators such as how many mutators exist, getting an iterator for all mutators, etc.Collection
: This trait implements functions related to garbage collection such as starting and stopping mutators, blocking current mutator thread for GC, etc.ObjectModel
: This trait implements the runtime's object model. The object model includes object metadata such as mark-bits, forwarding-bits, etc.; constants regarding assumptions about object addresses; and functions to implement copying objects, querying object sizes, etc. You should carefully implement and understand this as it is a key trait on which many things depend. We will go into more detail about this trait in the object model section.ReferenceGlue
: This trait implements runtime-specific finalization and weak reference processing methods. Note that each runtime has its own way of dealing with finalization and reference processing, so this is often one of the trickiest traits to implement.Scanning
: This trait implements object scanning functions such as scanning mutator threads for root pointers, scanning a particular object for reference fields, etc.Slot
: This trait implements a slot in an object, on the stack or other places (such as global variables). If a slot in your runtime simply holds the address of an object (or 0 for NULL references), you may use theSimpleSlot
type. But if your VM uses tagged pointers or compressed pointers, you will need to implement it manually.MemorySlice
: This trait implements functions related to memory slices such as arrays. This is mainly used by generational collectors.
For the time-being we can implement all the above traits via unimplemented!()
stubs. If you are using the Dummy VM binding as a starting point, you will have to edit some of the concrete implementations to unimplemented!()
. Note that you should change the type that implements VMBinding
from DummyVM
to an appropriately named type for your runtime. For example, the OpenJDK binding defines the zero-struct OpenJDK
which implements the VMBinding
trait.
Object model
The ObjectModel
trait is a fundamental trait describing the layout of an object to MMTk. This is important as MMTk's core doesn't know of how objects look like internally as each runtime will be different. There are certain key aspects you need to be aware of while implementing the ObjectModel
trait. We discuss them in this section.
Header vs Side metadata
Per-object metadata can live in one of two places: in the object header or in a separate space used just for metadata. Each one has its pros and cons.
Header metadata sits in close proximity to the actual object address but it is not easy to perform bulk operations. On the other hand, side metadata sits in a dedicated metadata space where each possible object address is assigned some metadata. This makes performing bulk operations easy and does not require stealing bits from the object header (there may in fact be no bits to steal for certain runtimes), but can result in large heap sizes given the metadata space is counted as part of the heap.
The choice of metadata location depends on the runtime and its object model and header layout. For example the JikesRVM runtime reserved extra space at the start of each object for GC-related metadata. Such space may not be available in your runtime. In such cases you can use side metadata to reserve per-object metadata.
Local vs Global metadata
MMTk uses multiple GC policies and each policy may use a different set of object metadata from each other. A moving policy, for example, may require extra metadata (in comparison to a non-moving policy) to store the forwarding bits and forwarding pointer. Such a metadata, which is local to a policy, is referred to as "local" metadata.
However, in certain cases, we may need to have metadata globally for the entire heap space. The classic example is the valid-object bit metadata which tells us if an arbitrary address is allocated/managed by MMTk. Such a metadata, which spans multiple policies, is referred to as "global" metadata.
For example, the Forwarding bits and pointer metadata is a local metadata used by copying policies to store forwarding bits (2-bits) and forwarding pointers (word size). Often runtimes require word-aligned addresses which means we can use the last two bits in the object header (due to alignment) and the entire object header to store the forwarding bits and pointer respectively. This metadata is almost always in the header.
We recommend going through the list of metadata specifications that are defined by MMTk. You should set them to locations that are appropriate for your runtime.
ObjectReference
vs Address
A key principle in MMTk is the distinction between ObjectReference
and Address
. The idea is that very few operations are allowed on an ObjectReference
. For example, MMTk does not allow address arithmetic on ObjectReference
s. This allows us to preserve memory-safety, only performing unsafe operations when required, and gives us a cleaner and more flexible abstraction to work with as it can allow object handles or offsets etc. Address
, on the other hand, represents an arbitrary machine address. You might be interested in reading the Demystifying Magic: High-level Low-level Programming paper which describes the above in more detail.
In MMTk, ObjectReference
is a special address that represents an object. It is required to be
within the address range of the object it refers to, and must be word-aligned. This address is used
by MMTk to access side metadata, and find the space or regions (chunk, block, line, etc.) that
contains the object. It must also be efficient to locate the object header (where in-header MMTk
metadata are held) and the object's VM-specific metadata, such as type information, from a given
ObjectReference
. MMTk will need to access those information, either directly or indirectly via
traits implemented by the binding, during tracing, which is performance-critical.
The address used as ObjectReference
is nominated by the VM binding when an object is allocated (or
moved by a moving GC, which we can ignore for now when supporting NoGC). VMs usually have their own
concepts of "object reference" which refer to objects. Some of them, including OpenJDK and CRuby,
uses addresses to the object (the starting address or at an offset within the object) to refer to an
object. Such VMs can directly use their "object reference" for the address of MMTk's
ObjectReference
.
Some VMs, such as JikesRVM, refers to an object by an address at a constant offset after the header,
and can be outside the object. This does not satisfy the requirement of MMTk's ObjectReference
,
and the VM binding needs to make a clear distinction between the VM-level object reference and
MMTk's ObjectReference
type. A detailed example for supporting such a VM can be found
here.
Other VMs may use tagged references, compressed pointers, etc. They need to convert them to plain
addresses to be used as MMTk's ObjectReference
. Specifically, if the VM use such representations
in object fields, the VM binding can deal with the encoding and the decoding in its
Slot
implementation, and always present plain ObjectReference
s to MMTk. See [this
test] for some Slot
implementation examples.
Miscellaneous configuration options
There are many constants in the ObjectModel
trait that can be overridden in your binding in order to meet your runtime's requirements. For example, the OBJECT_REF_OFFSET_LOWER_BOUND
constant which defines the minimum offset from allocation result start (i.e. the address that MMTk will return to the runtime) and the actual start of the object, i.e. the ObjectReference
. In other words, the constant represents the minimum offset from the allocation result start such that the following invariant always holds:
OBJECT_REFERENCE >= ALLOCATION_RESULT_START + OFFSET
We recommend going through the list of constants in the documentation and seeing if the default values suit your runtime's semantics, changing them if required.
MMTk initialization
Now that we have most of the boilerplate set up, the next step is to initialize MMTk so that we can start allocating objects.
In short, MMTk uses the builder pattern. The binding needs to create an MMTKBuilder
,
create an MMTK
instance from the builder, and then initialize MMTk's collection
when the runtime system is ready for GCs.
The following steps describes details. In an actual binding implementation, the binding may choose to combine several
steps into one function call to make things simpler.
- Create an
MMTKBuilder
usingMMTKBuilder::new()
. You can set runtime options viaset_option()
for things like the GC plan to use, heap sizes, etc. This is a full list of runtime options. You can also set options by directly accessing theoptions
in the builder, such asbuilder.options.threads.set(4)
. It is a common practice that the VM parses its command line arguments, then sets some GC-related options to MMTk here. You can also set virtual memory layout for MMTk. Some runtimes may require special layouts, such as using compressed pointers with a fixed heap range. However, both setting options and VM layouts are optional -- MMTk will use the default values if none is set. - Create an
MMTK
instance viamemory_manager::mmtk_init()
. This enables the binding to use most of the MMTk APIs inmemory_manager
, as most APIs require a reference toMMTK
. - When the runtime is ready for GCs (including getting its thread system ready to spawn GC threads), it is expected to call
memory_manager::initialize_collection
. Once the function returns, MMTk may trigger a GC at any appropriate time. In terms of getting NoGC to work, this step is optional, as NoGC will not trigger GCs.
In practice, it greatly depends on the runtime about how to expose the MMTk's Rust API above to native, and when to call the native API in the runtime.
In the following example, we assume a MMTKBuilder
is created statically (Step 1), and expects a call from the runtime to set heap sizes to the builder
via mmtk_set_heap_size()
. We will create an MMTK
instance from the builder in mmtk_init()
(Step 2). Step 3 is omitted, as we do not need it for NoGC.
Runtime-side changes
Create a mmtk.h
header file in the runtime folder of the binding (i.e. mmtk-X/X
) which exposes the functions required to implement NoGC and #include
it in the relevant runtime code. You can use the example mmtk.h
header file as an example.
Note: It is convention to prefix all MMTk API functions exposed with mmtk_
in order to avoid name clashes. It is highly recommended that you follow this convention.
Having a clean heap API for MMTk to implement makes life easier. Some runtimes may already have a sufficiently clean abstraction such as OpenJDK after the merging of JEP 304. In (most) other cases, the runtime doesn't provide a clean enough heap API for MMTk to implement. In such cases, it is recommended to create a class (or equivalent) that abstracts allocation and other heap functions like what the V8 and ART bindings do. This allows making minimal changes to the actual runtime and having a concrete implementation of the exposed heap API in the binding, reducing MMTk-specific code in the runtime. Ideally these changes are upstreamed like in the case of V8.
It is also recommended that any change you do in the runtime be guarded by build-time flags as it helps in maintaining a clean port.
At this step, your mmtk.h
file may look something like this:
#ifndef MMTK_H
#define MMTK_H
#include <stddef.h>
#include <sys/types.h>
// The extern "C" is only required if the runtime
// implementation language is C++
extern "C" {
// An arbitrary address
typedef void* Address;
// MmtkMutator should be an opaque pointer for the VM
typedef void* MmtkMutator;
// An opaque pointer to a VMThread
typedef void* VMThread;
/**
* Initialize MMTk instance
*/
void mmtk_init();
/**
* Set the heap size
*
* @param min minimum heap size
* @param max maximum heap size
*/
void mmtk_set_heap_size(size_t min, size_t max);
} // extern "C"
#endif // MMTK_H
Now we can initialize MMTk in the runtime. Note that MMTk should ideally be initialized around when the default heap of the runtime is initialized. You will have to figure out where is the best location to initialize MMTk in your runtime.
Initializing MMTk requires two steps. First, we set the heap size by calling mmtk_set_heap_size
with the initial heap size and the maximum heap size. Then, we initialize MMTk by calling mmtk_init
. In the future, you may wish to make the heap size configurable via a command line argument or environment variable (See setting options for MMTk).
MMTk-side changes
On the Rust side of the binding, we want to implement the two functions exposed by the mmtk.h
file above. We use an MMTKBuilder
instance to actually create our concrete MMTK
instance. We recommend following the paradigm used by all our bindings wherein we have a static
single MMTK
instance and an MMTKBuilder
instance that we can use to set relevant options. See the OpenJDK binding for an example.
Note: MMTk currently assumes that there is only one MMTK
instance in your runtime process. Multiple MMTK
instances are currently not supported.
The mmtk_set_heap_size
function is fairly straightforward. We recommend using the implementation in the OpenJDK binding. The mmtk_init
function is straightforward as well. It should simply manually initialize the MMTK
static
variable using lazy_static
, like here in the OpenJDK binding.
By this point, you should have MMTk initialized. If you are using a debug build (which is recommended) and have logging turned on a message similar to below would be printed out:
[...]
[INFO mmtk::memory_manager] Initialized MMTk with NoGC (FixedHeapSize(10485760))
[...]
Binding mutator threads to MMTk
For MMTk to allocate objects, it needs to be aware of mutator threads. MMTk only allows mutator threads to allocate objects. We do this by "binding" a mutator thread to MMTk when it is initialized in the runtime.
Runtime-side changes
Add the following function to the mmtk.h
file:
[...]
/**
* Bind a mutator thread in MMTk
*
* @param tls pointer to mutator thread
* @return an instance of an MMTk mutator
*/
MmtkMutator mmtk_bind_mutator(VMThread tls);
[...]
The mmtk_bind_mutator
function takes in an opaque pointer representing an instance of the runtime's mutator thread and returns an opaque pointer to a Mutator
instance back to the runtime. The runtime must store this pointer somewhere, preferably in its runtime thread local storage implementation, as MMTk requires a Mutator
instance to allocate and perform other actions.
The placement of the mmtk_bind_mutator
call in the runtime depends on the runtime's implementation of its thread system. It is recommended to call mmtk_bind_mutator
when the runtime initializes the thread local storage of a newly created thread. This ensures that the thread can allocate from MMTk immediately after initialization.
MMTk-side changes
The Rust side of the binding should simply defer the actual implementation to mmtk::memory_manager::bind_mutator
. See the OpenJDK binding for an example.
Allocation
Now we can finally implement the allocation functions.
Runtime-side changes
Add the following two functions to the mmtk.h
file:
[...]
/**
* Allocate an object
*
* @param mutator the mutator instance that is requesting the allocation
* @param size the size of the requested object
* @param align the alignment requirement for the object
* @param offset the allocation offset for the object
* @param allocator the allocation semantics to use for the allocation
* @return the address of the newly allocated object
*/
void *mmtk_alloc(MmtkMutator mutator, size_t size, size_t align,
ssize_t offset, int allocator);
/**
* Set relevant object metadata
*
* @param mutator the mutator instance that is requesting the allocation
* @param object the ObjectReference address chosen by the VM binding
* @param size the size of the allocated object
* @param allocator the allocation semantics to use for the allocation
*/
void mmtk_post_alloc(MmtkMutator mutator, void* object, size_t size, int allocator);
[...]
In order to perform allocations, you will need to know what object alignment the runtime expects. Runtimes often align allocations at word boundaries (i.e. 4- or 8-bytes) as it allows the CPU to access the data faster at execution time. Additionally, the runtime may use the unused lowest order bits to store flags (e.g. type information), so it is important that MMTk respects these expectations. Once you have figured out the alignment requirements for your runtime, you should update the MIN_ALIGNMENT
constant in VMBinding
to the correct value.
Now that MMTk is aware of each mutator thread, you have to change the runtime's allocation functions to call into MMTk to allocate using mmtk_alloc
and set object metadata using mmtk_post_alloc
. Note that there may be multiple allocation functions in the runtime so make sure that you edit them all!
When calling mmtk_alloc
, you should use the saved Mutator
pointer as the first parameter, the requested object size as the next parameter, and any alignment requirements the runtimes has as the third parameter.
If your runtime requires a non-zero allocation offset (i.e. the alignment requirements are for the offset address, not the returned address) then you have to provide the required value as the fourth parameter. Note that you must also update the USE_ALLOCATION_OFFSET
constant in the VMBinding
implementation if your runtime requires a non-zero allocation offset.
For the time-being, you can ignore the allocator
parameter in both these functions and always pass a value of 0
which means MMTk will pick the default allocator for your collector (a bump pointer allocator in the case of NoGC).
The return value of mmtk_alloc
is the starting address of the allocated object.
Then you should nominate a word-aligned address within the allocated bytes to be the
ObjectReference
used to refer to that object from now on. It doesn't have to be the starting
address.
Finally, you need to call mmtk_post_alloc
with your chosen ObjectReference
in order to
initialize MMTk-level object metadata, such as logging bits, valid-object (VO) bits, etc. As a VM
binding developer, you can ignore the details for now.
Note: Currently MMTk assumes object sizes are multiples of the MIN_ALIGNMENT
. If you encounter errors with alignment, a simple workaround would be to align the requested object size up to the MIN_ALIGNMENT
. See here for the tracking issue to fix this bug.
MMTk-side changes
The Rust side of the binding should simply defer the actual implementation to mmtk::memory_manager::alloc
and mmtk::memory_manager::post_alloc
respectively. See the OpenJDK binding for an example.
Congratulations! At this point, you hopefully have object allocation working and can run simple programs with your runtime using MMTk!
Miscellaneous implementation steps
Setting options for MMTk
The preferred method of setting options for MMTk is by setting them via the MMTKBuilder
instance. See here for an example in the OpenJDK binding.
The process
function can also be used to pass options. You may want to set multiple options at the same time. In such a case you can use the process_bulk
function.
MMTk also supports setting options via environment variables. This is generally only recommended at early stages of the porting process in order for quick development. For example, to use the NoGC plan, you can set the environment variable MMTK_PLAN=NoGC
.
A full list of available options that you can set can be found here.
Runtime-specific steps
Often it is the case that the above changes are not enough to allow a runtime to work with MMTk. For example, for the ART binding, the runtime required that all inflated locks be deflated prior to writing the boot image. In order to fix this, we had to implement a heap visitor that visited each allocated object and checked if it had inflated locks, deflating them if they were.
Unfortunately there is no real magic bullet here. If you come across a runtime-specific idiosyncrasy (and you almost certainly will), you will have to understand what the underlying bug is and either fix or work around it.
If you have any confusions or questions, please free to reach us on our Zulip! We would be glad to help.