Skip to content

Latest commit

 

History

History
104 lines (85 loc) · 5.71 KB

memory.md

File metadata and controls

104 lines (85 loc) · 5.71 KB

RhizomeRuby

Memory system

The memory system allows us to allocate the native memory that we need in which to store the machine code generated by our just-in-time compiler. We need to allocate our own native memory to store machine code because the processor will only execute code in memory with the executable permission, and higher level languages like Ruby don't give us anything to set memory permissions.

We implement our memory system using the system calls mmap, mprotect, and munmap, which we call using the different foreign function interfaces of the implementations of Ruby on which we run.

Why we need it

We need memory in which to store the machine code which we will generate. One reason that we can't use normal Ruby objects, such as a String or Array for this, is because Ruby's garbage collector can move objects around in memory without telling us, and we need to be able to rely on being able to call our machine code with a fixed address. We also can't use normal Ruby objects, or even Ruby's underlying mechanism for allocating native memory, malloc, because we need our memory to have the executable permission. Our processor won't execute instructions from memory without this permission. It's the operating system kernel that can set memory access permission, so we need memory that the kernel is more managing. This is what the Rhizome memory system does - it asks the kernel directly for native memory, it can set the read, write and execute permissions, it can read and write the memory, and it can call the memory as a machine code function.

The Rhizome memory system is therefore a low level tool of our just-in-time compiler, but a key one that unlocks the magic door that we need out of Ruby and into the world where we can run our own machine code.

How it works

The Rhizome memory system allocates memory using the mmap system call. A system call is a library function provided by the operating system kernel. Like many system calls, mmap does quite a lot. You may have heard of mmap in the context of making files available as memory without having to manually read all the data in. We use mmap in 'anonymous' mode, which means that we don't want to load a file and we don't want to save any changes back to a file - we just want the memory space.

A second system call mprotect allows us to change the access permissions for memory that we've already allocated with mmap. We want to change the permissions rather than just set them once when allocating, becuase we want to have the memory writable but not executable while we write the code to it, and then executable but not writable while we run it. This can increase safety, and some operating systems require it.

The final system call that we use is munmap, which frees memory that we no longer need. We do this manually as soon as we know we will no longer need the memory, because Ruby's garbage collector doesn't know how much space it is consuming and so may not know that it would be useful to free it when we become short on space. We do use the Ruby garbage collector to free our native memory if we forget to do it ourselves, such as if an exception is thrown and our code to free it manually is not run.

Those are the system calls that we use. To make these system calls from Ruby without using any gems we have to use a different mechanism for each implementation of Ruby. On MRI we use the fiddle standard library. Rubinius doesn't support fiddle, so there we use their built-in ffi library. JRuby does support fiddle, but its version is not compatible with MRI, so there we also have to use their built-in ffi library, which isn't quite the same as the one in Rubinius. All of these libraries do the same two things though - they let us call native functions from Ruby, and then let us read and write native memory based on native addresses.

We could simplify our code here. If we used the ffi gem, rather than fiddle or the versions of ffi built into Rubinius and JRuby, then the interface could be standard for all implementations, but we have the self-imposed goal of running on standard Ruby implementations with no additional code.

More technical details

Calling mmap every time we want to allocate memory for a compiled method is probably inefficient. I think it only works at the granularity of 'pages' of memory as the kernel manages them, so even if we only need a few hundred bytes, the kernel will round it up to a multiple of four thousand bytes or so. If we wanted to run real Ruby applications this may add up to a great deal of wasted space very quickly.

Most production just-in-time compilers will abstract from the kind of memory system we have here and will implement a 'code cache'. They will allocate the underlying memory that they need in the same way, but instead of doing it each time they compile a method, they will allocate a large chunk of memory and then fit compiled code into it as needed. The code cache will also have logic to remove compiled code when space becomes limited or the method is modified. This has all the usual complexities of caching - do you remove the largest compiled code? The least recently used? One at random?

We don't often think about just-in-time compilers as being a cache, but becuase you could recompile methods every time they were called if you wanted to, we can indeed think of storing the compiled version as just another cache.

Potential projects

  • Implement a code cache that requests large blocks of memory and allocates from them, rather than calling mmap for each allocation.
  • Implement code cache eviction when there is memory pressure.