I picked the problem of my macbook pro being an absolute snail under load, mostly because I wanted to play around and experiment while learning something.
Sure I got my standard VS Code, a terminal, a browser, slack, postman and some other programs running, but there comes a time when I change something in my code and in response the macbook starts preparing for a lift off.
You see I have this typescript project, not too big, but not too small either. I keep a build task running in background which incrementally compiles code whenever I change it.
And here is the usual ram usage:
Looking up and fixing VS Code ram usage is fairly easy, but what about that node process? That’s the one I have no idea how to fix. I mean it compiles stuff and it takes a lot of juice, that’s all I know.
I can’t throw hardware at this problem! Or so I thought. Well I can’t upgrade my RAM or my CPU even for that matter. But, I do have nice PC sitting idle when I work. It has 16G of RAM, and a Ryzen 1600 for CPU. Fun fact, Ryzen 1600 is supposed to be a 6-core processor but somehow AMD manafacturing plants messed up and released a few 1600s with 8 cores. And somehow I got lucky.
The problem
The problem is straightforward, use the PC to compile the code residing on my macbook.
But I have come to depend on nodemon and auto reload on change. And that’s something I don’t want to miss out on. So let’s throw in this constraint as well. The solution should work as good as shown below where nodemon restarts my server as soon as I change a file and save it:
The solution
What if we listened for changes in our filesystem, especially if done to a particular directory, and then we create a zip, send it to PC over network, the PC compiles it, and sends back a zip, we unzip it? Voila, we have got ourselves a working solution! And along with that we have got ourselves a whole lot of lag! This would slow down to the point of defeating the whole purpose.
I am quite convinced that sending code back and forth will be a bottleneck if I come up with something of my own. It’d be better if we lean on something native, I mean this is a problem someone else must have solved. And sure someone had. It’s called NFS(Network File system), and it’s quite old and widely used.
The idea is simple, mount macbook’s disk on PC and then ask PC to compile.
+--------------------------+
| +--------------+ |
| |Compiler | |
| +-+------------+ |
| | |
| | |
| |Compile mounted |
| |project |
| | |
| | |
| | |
+---v---------+ |
| | |
+---------+ | | |
| +-------------->+ Mounted | |
| Macbook | Mount | Project | |
| | | | |
+---------+ | | |
| | |
+-------------+ |
| |
| |
| |
| |
| |
| |
| PC |
+--------------------------+
Figure 1
And as the name Network File System
suggests, it lets you access remote machines’ disk as if you are accessing it locally. And that’s the beauty of it. Anything I change on my machine, gets transmitted to the remote machine instantly(well ignoring some network latency) and vice versa.
Explaination
Figure 1 sheds some light on this simple architecture. The remote machine has the compiler installed, and to actually compile it, all that needs to be done is compile the directory.
And yes, that directory is a mounted one but that doesn’t matter! It’s a directory nonetheless and the compiler is well versed in talking to it.
Well, ok, sure the compiler can handle a mounted directory but does it help our cause? Absolutely!
The directory is mounted on remote machine, and it has the compiler as well. It’s pretty straightforward for the remote machine to go ahead and provide some resources to the compiler and let it do its job.
Offloading succesfull!
Setup
I tried this solution and first I needed to setup NFS server on my mac. NFS Manager is a nice little free tool that came handy. It’s easy to configure and lets you control a lot of things.
Now that my macbook is mountable, I needed to tell my PC to actually mount it. My PC runs a Ubuntu, so all the commands for PC will be linux specific.
In essence, I need to use the mount
command and tell it the IP address of my macbook and the directory it’s sharing.
Zero Conf
It enables any device to find other devices on the network and contact them. So if a device has the zero-conf daemon running, and is brought on to a network, it has the capability to find other zero-conf enabled devices.
But what does it mean to ‘know’ devices on the network? Well a part of the answer is the name of the device, or more specifically the hostname.
mDNS(Multicast DNS)
is another service on which Zero Conf relies on and it powers the hostname resolution for connected devices.
Each device on the network has a name it can use, and by default other devices can access it through the hostname: <device-name>.local
Well access is not quite right, just like DNS, mDNS is also a protocol which maps hostnames to IP addressess. And that’s all it does, it resolves the hostname into a valid IP address.
Once you have the IP you can basically do all the networking you want to do.
The beauty is, you don’t have to remeber ip addresses anymore, if your device restarts and your router gives it a different IP address, it simply doesn’t matter. We have a hostname for the device now, and mDNS to resolve it.
It comes by default in macOS, and in fact you can test it right away. <your-laptop's-name>.local
is the hostname of your macbook. And if you have a server running(can try running with python -m SimpleHTTPServer
), you can access it using that hostname.
Setting up NFS
Let’s continue our NFS journey with this newfound knowledge of .local
domains. In my setup, the name of my PC is kaer-morhen
and my macbook is named bianco
.
Once again:
PC/Remote machine = kaer-morhen
Macbook = bianco
I have created a directory on PC as /mnt/bianco
. If a new device comes up it’ll follow the same pattern. Now, mounting is simple, as discussed before, we will have to use the mount command, like this:
sudo mount bianco.local:/System/Volumes/Data/Users/iostreamer /mnt/bianco
This comes with a limitation that one’d have to mount it everytime I boot the PC. To automate it, I followed the steps mentioned here and created an entry in /etc/fstab
and let the OS handle it for me. Now all that mounting stuff is done behind the scenes automatically!
Voila! Our macbook will be automatically mounted on PC whenever the PC boots.
Setting up Compiler
For this particular project, I need the typescript compiler. Installing it is as simple as:
npm i -g typescript
To compile the project all one needs to do is run the command tsc
in the project directory. This parses your tsconfig.json, picks up source directories, compiles file and puts them in the output directory. But we also need it to watch for changes done to the source directories and then recompile if anything changes. That can be done with compiling in watch mode: tsc -w
. This would block your shell and start watching for changes.
Here’s a script I wrote which SSHs
into the remote machine, goes to the appropriate directory and starts compilation in watch mode. I named this script rcw
(Remote compile watch). It basically picks up the current working directory as string, and replaces folder names such that it matches the mount folder on remote machine:
#!/usr/bin/env zsh
cwd=`pwd`
cwd="${cwd/Users/mnt}" // Syntax for replacing substrings
cwd="${cwd/iostreamer/bianco}"
ssh -tt iostreamer@kaer-morhen.local "cd $cwd && tsc -w"
Let’s try to run it, change the files and see if automatically compiles the changes or not:
Lo and behold, it doesn’t work!
What did we miss? Did we miss something? Let’s find out!
The catch
Why did it not work? We have mounted correctly, it even compiles properly and one can even see the compiled files locally. Then what’s stopping it to react to changes done to files?
Well, to be blunt, it does work. It’s just clunk and slow in my experience(YMMV).
What is inotify?
Well inotify
is a Linux API/ Sub system. You can ask this API to tell you whenever a change is done to a directory or its files. When you run tsc -w
, i.e. compile in watch mode, that’s exactly what the typescript compiler does. It sets up a watcher, which is notified when source directories change.
In our case, the NFS client running on linux(PC) is the one handling changes done by macbook. And these changes are all immediate, very snappy. You can verify by creating a file locally and then SSHing
in to remote machine and using the watch
command to see the contents of the file at let’s say an interval of 0.1s. As soon as you change the contents of the file locally, you’d see it getting reflected immediately through the watch
command.
The issue seems to be with NFS client and inotify. It’s not that it doesn’t work, but in my experience it’s quite slow, to the extend you can’t depend on it. From what I observed, the content of the file change immediately but the NFS client takes its time to contact inotify. Eventually it does, but who wants to wait?
The solution: Part 2
After Googling for like 5 minutes I came across this interesting project called notify-forwarder. It works on a very simply principle, forward it manually.
It’s a program which works in 2 modes:
- watch
- When working in watch mode, it listens for changes locally and transmits those changes to the specified machine.
- When watching, you have to specify the remote ip, as well as the remote directory path.
- receive
- When working in receive mode, it simply listens for changes done to the directory.
To be more precise, this program simply tells some
change has been done to a file. And when listening for events(receive mode), it simulates an ATTRIB
event. This event is fired when an attribute of the file is changed.
And the obvious drawback is that, not all compilers/build systems respect ATTRIB
events.
Thankfully, typescript does.
Now, all we have to do is run this program in receive mode on my PC. And then in watch mode locally. For PC, I didn’t want to run it manually, so I created a service, which can be handled by systemd
.
Note: I had no prior experience with systemd services and followed this guide to create it.
Script to run in receive mode
notify-forwarder receive
.service file for the script
[Unit]
Description=Notify Forwarder
[Service]
Type=simple
ExecStart=/bin/bash /usr/local/bin/notify_receive
[Install]
WantedBy=multi-user.target
For my macbook, I needed a script which runs notify-forwarder
in watch mode, watches the current directory and sends the changes to PC(kaer-morhen). I named it nk
(Notify Kaer-Morhen)
Here’s the script for same:
ip=`dscacheutil -q host -a name kaer-morhen.local | grep ip_address | awk -v FS=': ' '{print $2}'`
cwd=`pwd`
cwd="${cwd/Users/mnt}"
cwd="${cwd/iostreamer/bianco}"
notify-forwarder watch -c $ip . $cwd
And this is how it looks when it all comes together:
Conclusion
This was a day’s hack and I got to learn so much! But all of it fades when compared to this one learning. You see, before starting I thought achieving something like this smoothly would need some genius level hacks or code.
But it did not. I just stumbled from one problem to another till I managed to hack together a part elegant, a part messy solution. This is definitely not a genius level code.
Getting it done >
Waiting to learn some godsent tools and creating that perfect thing.
Future
This is definitely not a Compiler as a Service
product and I doubt it’s going to be for a long time. But that’s not going to stop me from monetising it in my home. My roommate has a similar setup, and I am thinking of an AWS like model where 1 CPU minute = 2 dishes they wash.
Top comments (5)
Hey; I wandered in from android's news feed showing me your article, but fwiw this sort of dual laptop/desktop / desktop-building-expensive-artifacts is exactly what I ended up building mirror to support:
github.com/stephenh/mirror
It does two-way, inotify-based sync. Disclaimer its built on top of Facebook's watchman and Java/the JVM, so you'll need both installed to use it, but might be a good solution to your problem.
Hey Stephen
This looks super cool, quite detailed and mature. I wish I had found it earlier.
Amazing job!
Just to anyone reading this, I had faced a similar issue when testing of my a VM. my solution simply involved SSHFS which with a single command did everything you did with your networking solutions. As long as the SSH server is installed, the command is simply
sshfs user@host:/source ./build
And you can use inotity to watch and rerun build/automation scripts periodically. It also has the bonus of not requiring additional tooling because most of the features come with Linux
This is very good for the learning experience, thanks for sharing. Now, if you want fantastic performance, replace nodemon with ts-node-dev, it is a world of difference.
You're a genius haha. It didnt even occur to me that this could be an option 👍