Blog Post

Server Development on Apple Silicon

Reinhard Hafenscher
Illustration: Server Development on Apple Silicon

In my last post, I outlined our journey of getting our server products, PSPDFKit Web Server-Backed and PSPDFKit Processor, running on ARM-based hardware. Today, I want to share how we got Apple silicon working on our Server-based products and give you my perspective of where it works and where it doesn’t.

Getting Set Up

The first thing that needs to be discussed is getting set up. The tech stack we use for our server-based products consists of:

  • Erlang and Elixir for running our main Server application

  • Our pspdfkitd binary, which is based on our shared core that we use for all PDF-related operations

  • Node.js for running tests against our HTTP API

  • Ruby for internal tooling around development

Let’s see if we can make this work on Apple silicon.

External Dependencies

We use the asdf version manager for managing all our dependencies. For context, this was our .tool-versions file when we started this:

nodejs 10.15.1
elixir 1.11.2-otp-23
erlang 23.2.1
ruby 2.7.1

Let’s try installing the above on an M1 MacBook — after adding the relevant plugins, of course:

$ asdf install
/Users/user/.asdf/plugins/nodejs/bin/../lib/utils.sh: line 62: printf: write error: Broken pipe
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:-- 100  4229    0  4229    0     0  13511      0 --:--:-- --:--:-- --:--:-- 13511
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:-- 100 10860    0 10860    0     0  32612      0 --:--:-- --:--:-- --:--:-- 32612
Binary not found for version 10.15.1

So we already hit our first road block: The Node.js plugin for asdf relies on prebuilt binaries for each supported platform. Sadly, at the time of writing, there wasn’t a binary available for ARM-based MacBooks. Luckily, Node itself does support ARM, so if you build it yourself, it should work. Let’s try using nvm:

$ nvm install 10.15.1
…
#error Target architecture arm64 is only supported on arm64 and x64 host
 ^
1 error generated.
1 error generated.
make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/file-utils.o] Error 1
make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/functional.o] Error 1
1 error generated.
make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/ieee754.o] Error 1
make: *** [node] Error 2
nvm: install v10.15.1 failed!

Still no luck. After some additional research, I found a suggestion that said using a newer version of Node might actually work. Let’s give that a shot:

$ nvm install 15.11.0
…
Now using node v15.11.0 (npm v7.6.0)
$ node --version
v15.11.0

It took a couple of minutes, but we now have Node running on our M1 MacBook. Let’s remove the asdf plugin and try installing our dependencies again:

$ asdf plugin remove nodejs
$ asdf install
Downloading openssl-1.1.1i.tar.gz...
-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242
Installing openssl-1.1.1i...
Installed openssl-1.1.1i to /Users/user/.asdf/installs/ruby/2.7.1

Downloading ruby-2.7.1.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.7/ruby-2.7.1.tar.bz2
Installing ruby-2.7.1...
ruby-build: using readline from homebrew

BUILD FAILED (macOS 11.2.1 using ruby-build 20201225)

Inspect or clean up the working tree at /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T/ruby-build.20210311160726.52882.JhuVIJ
Results logged to /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T/ruby-build.20210311160726.52882.log

Last 10 log lines:
compiling psych_parser.c
compiling psych_to_ruby.c
compiling psych_yaml_tree.c
compiling ../.././ext/psych/yaml/reader.c
compiling ../.././ext/psych/yaml/scanner.c
compiling ../.././ext/psych/yaml/writer.c
linking shared-object stringio.bundle
linking shared-object zlib.bundle
linking shared-object psych.bundle
make: *** [build-ext] Error 2

It’s clear that we’re still in uncharted territory here. After some more research, it turned out that a simple version bump is all that’s required. Changing our Ruby version from 2.7.1 to 2.7.2 seems to do the trick:

$ asdf install
Downloading openssl-1.1.1i.tar.gz...
-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242
Installing openssl-1.1.1i...
Installed openssl-1.1.1i to /Users/user/.asdf/installs/ruby/2.7.2

Downloading ruby-2.7.2.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.7/ruby-2.7.2.tar.bz2
Installing ruby-2.7.2...
ruby-build: using readline from homebrew
Installed ruby-2.7.2 to /Users/user/.asdf/installs/ruby/2.7.2

$ ruby --version
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [arm64-darwin20]

Another 10 minutes later and we have Ruby running as well. Interestingly, if you check the changelog, you’ll find no references to any fixes made for ARM support, and indeed, some other people report that even 2.7.2 only installs in Rosetta. However, I had no issues, and as you can see from the output, it is indeed running as a native ARM64 binary. Let’s see if Erlang and Elixir will cause issues as well:

$ asdf_23.2.1 is not a kerl-managed Erlang/OTP installation
No build named asdf_23.2.1
Extracting source code
Building Erlang/OTP 23.2.1 (asdf_23.2.1), please wait...
…

Erlang/OTP 23.2.1 (asdf_23.2.1) has been successfully built
Installing Erlang/OTP 23.2.1 (asdf_23.2.1) in /Users/user/.asdf/installs/erlang/23.2.1...

$ erl
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]

Eshell V11.1.5  (abort with ^G)
1>

That was painless. Seems like Erlang already supports Apple silicon in our current version. What does Elixir say?

$ asdf install
==> Checking whether specified Elixir release exists...
==> Downloading 1.11.2-otp-23 to /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T//elixir-precompiled-1.11.2-otp-23.zip
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--   0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:-- 100 5720k  100 5720k    0     0  8897k      0 --:--:-- --:--:-- --:--:-- 8883k
==> Copying release into place

$ elixir --version
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]

Elixir 1.11.2 (compiled with Erlang/OTP 23)

Now we’re all done, and we have our dependencies installed and running!

The takeaway here is that unless your tech stack is really esoteric (or using very old versions), chances are it should be relatively easy for you to make it work on Apple silicon. If you already try to stay up to date with your versions, you’ll most likely not even need to do anything beyond installing them as usual.

Our pspdfkitd Binary

With all our external dependencies installed, there was one more component we required when working on PSPDFKit Web Server-Backed or Processor, and that’s our PDF processing tool. This is what handles all PDF-related operations. In our release, it’s packaged inside the Docker image we ship, but when working locally, we need it in our path. Usually, we put it in the /usr/local/ folder, but on the M1 MacBook, by default, /usr/local/bin/, /usr/local/share/, and /usr/local/lib/ are owned by root. And since Homebrew also puts ARM binaries in its own /opt/homebrew/ folder, we decided it’s best to follow suit and not require sudo.

We don’t have a native ARM version of pspdfkitd for macOS yet (we do have one for Linux, which we use for our Web Server-Backed and Processor releases), but it runs with no issues in Rosetta 2. So all we had to do was adjust our installation script to use the new paths as outlined above. It looks something like this in action:

if [[ $OSTYPE == darwin* && `uname -m` == "arm64" ]]; then
  # M1 MacBook use /opt/homebrew
  mv /tmp/pspdfkitd /opt/homebrew/bin
else
  # Use /usr/local
  mv /tmp/pspdfkitd /usr/local/bin
fi

We rely on Homebrew having already added /opt/homebrew/ to the path on our development machines, so we don’t need any additional setup.

The Development Experience

Considering setting up wasn’t much harder than on a regular Mac, how is the development experience? It has its ups and downs; let’s take a closer look.

The Good: Writing and Running Code

This is where the new M1 MacBooks really shine: They’re completely silent, they barely get warm, and they’re more than fast enough to handle working on our Elixir codebase. You’ve got all the tools you need to work, since things like Visual Studio Code already have Apple silicon-compatible versions. And even things that don’t yet, such as SmartGit (the git program of choice for me), work perfectly fine in Rosetta 2.

Now, there are already many websites that have benchmarked the performance of the new Apple silicon Macs against their Intel counterparts, so I won’t get too much into that. But if you work with Elixir and are worried about compile times, fear not: I’ve got the data for you. I’m comparing my 2019 16” MacBook Pro (2,4 GHz 8-Core Intel Core i9, 32 GB 2667 MHz DDR4) with my 2020 MacBook Air (M1, 16 GB). Let’s look at the time output of a clean compile of PSPDFKit Web Server-Backed.

2019 MacBook Pro Results:

$ time mix compile
mix compile  299.75s user 21.70s system 362% cpu 1:28.72 total
mix compile  297.25s user 21.79s system 365% cpu 1:27.23 total
mix compile  299.36s user 22.15s system 359% cpu 1:29.33 total
mix compile  310.94s user 22.60s system 362% cpu 1:32.00 total

2020 MacBook Air Results:

$ time mix compile
mix compile  102.73s user 23.90s system 213% cpu 59.451 total
mix compile  103.15s user 22.52s system 199% cpu 1:02.96 total
mix compile  103.15s user 22.52s system 199% cpu 1:02.96 total
mix compile  101.87s user 23.41s system 214% cpu 58.460 total

Now those results are just impressive, though it’s also a slightly unfair comparison. This is because the 2019 MacBook I used for comparison has been my main workhorse for almost a year at this point, and it has much more stuff installed on it and running in the background. Meanwhile, the M1 MacBook Air is essentially straight out of the box with only the bare minimum installed and running. That being said, the giant 16” MacBook taking three times as long for a clean compile is still a bad result, even considering other stuff running.

There isn’t much else to add here. If you work with Elixir and run it straight from the command line (as opposed to in Docker, which we’ll get to next), the new M1 MacBook is great.

The Bad: Docker

This brings me to what isn’t great, and that’s Docker. While there’s a Docker version that runs on Apple silicon, and all images that support ARM64 work with it, it’s much slower than on Intel. For us, during development, that doesn’t really matter, since we run our entire server directly on the Mac. However, on occasion, we have to check if changes we make to our Docker build work, and that means building Docker images.

Let’s compare how long it takes to build our base image using the same hardware as above. Building this Docker image includes compiling all runtime dependencies we need for PSPDFKit Web Server-Backed — such as Ruby — and pulling dependencies via APT.

2019 MacBook Pro Results:

$ time docker-compose build --no-cache base

real    6m11.356s
user    0m0.966s
sys     0m0.366s

real    6m2.826s
user    0m1.004s
sys     0m0.387s

2020 MacBook Air Results:

$ time docker-compose build --no-cache base

real    11m58.859s
user    0m4.526s
sys     0m1.748s

real    12m19.955s
user    0m4.544s
sys     0m1.833s

As you can see, neither is really fast (to be fair, our CI also takes six minutes to build this image), but the M1 MacBook is much slower here, taking twice as long. This, I think, comes down to the file system performance for Docker on Mac being not great, which makes it an even bigger bottleneck on the M1 MacBook. That being said, it’s still early for Docker on Apple silicon, and there isn’t even a stable release out yet, so this surely will get better in the future. For now, I’d recommend using remote builders when using Docker on Apple silicon. As long as your connection is relatively fast, this will be much quicker than trying to build locally, and it’s a great way to see if your build context is bloated.

As for running Docker images, as long as they’re built for ARM64, this works just fine (this includes PSPDFKit Web Server-Backed and Processor as well). There isn’t anything to complain about performance-wise either; it’s just building them that’s too slow for productive work.

With that, let’s wrap up.

Conclusion

Assuming your tools are supported on Apple silicon and you don’t rely heavily on Docker, the new M1 Macs are great for development. Many of the tools we as developers rely on are natively supported, and for those tools that aren’t yet, Rosetta 2 works well. I only looked into Elixir development today, but Apple silicon should do great with almost anything.

I hope this gave you an idea of what to expect if you decide to pick up one of the new M1 Macs for development. Be sure to let us know if there’s anything else we can answer.

If you want to learn more about ARM at PSPDFKit, you can also check out our previous post, where we looked at how we added ARM support to our PSPDFKit Web Server-Backed and Processor products.

Related products
Share post
Free trial Ready to get started?
Free trial