[ANN] JuliaFromMATLAB.jl - Call Julia from MATLAB

Inspired by this recent discussion regarding the state of calling Julia from MATLAB, I have put together a package to try to resolve some of the issues faced by previous approaches: JuliaFromMATLAB.jl. Please scroll down to see the Quickstart guide, pasted here from the package’s README for convenience.

In particular, I have chosen to avoid trying to keep up with the MATLAB Mex C/C++ interface. Instead, communication between MATLAB and Julia is performed by writing to temporary .mat files. This decision was made with the hope that it will allow JuliaFromMATLAB.jl to “just work”, if at the cost of some performance.

To that end, I would like to thank @dmolina for their work in developing DaemonMode.jl, used extensively in this package. With DaemonMode.jl, users of JuliaFromMATLAB.jl have the option to keep persistent Julia instances active in the background, eliminating the need to re-compile included Julia code on every call from MATLAB, drastically reducing overhead.

Lastly, this package is not yet registered, mostly because I am not fully committed to the name JuliaFromMATLAB.jl - suggestions welcome!

Quickstart

Use the MATLAB function jlcall.m to call Julia from MATLAB:

>> jlcall('sort', {rand(2,5)}, struct('dims', int64(2)))

ans =

    0.1270    0.2785    0.6324    0.8147    0.9575
    0.0975    0.5469    0.9058    0.9134    0.9649

The positional arguments passed to jlcall.m are:

  1. The Julia function to call, given as a MATLAB char array. This can be any Julia expression which evaluates to a function. For example, 'a=2; b=3; x -> a*x+b'. Note: this expression is wrapped in a let block and evaluated in the global scope
  2. Positional arguments, given as a MATLAB cell array. For example, args = {arg1, arg2, ...}
  3. Keyword arguments, given as a MATLAB struct. For example, kwargs = struct('key1', value1, 'key2', value2, ...)

The first time jlcall.m is invoked:

  1. JuliaFromMATLAB.jl will be installed into a local Julia project, if one does not already exist. By default, a folder .jlcall is created in the same folder as jlcall.m
  2. A Julia server will be started in the background using DaemonMode.jl

All subsequent calls to Julia are run on the Julia server. The server will be automatically killed when MATLAB exits.

Restarting the Julia server

In the event that the Julia server reaches an undesired state, the server can be restarted by passing the 'restart' flag with value true:

>> jlcall('x -> sum(abs2, x)', {1:5}, 'restart', true)

ans =

    55

Julia multithreading

The Julia server can be started with multiple threads by passing the 'threads' flag:

>> jlcall('() -> Base.Threads.nthreads()', 'threads', 8, 'restart', true)

ans =

  int64

   8

The default value for 'threads' is given by the output of the MATLAB function maxNumCompThreads.

Note: Julia cannot change the number of threads at runtime. In order for the 'threads' flag to take effect, the server must be restarted.

Loading modules

Julia modules can be loaded and used:

>> jlcall('LinearAlgebra.norm', {[3.0; 4.0]}, 'modules', {'LinearAlgebra'})

ans =

     5

Note: modules are loaded using import, not using. Module symbols must therefore be fully qualified, e.g. LinearAlgebra.norm in the above example as opposed to norm.

Persistent environments

By default, previously loaded Julia code is available on subsequent calls to jlcall.m. For example, following the above call to LinearAlgebra.norm, the LinearAlgebra.det function can be called without loading LinearAlgebra again:

>> jlcall('LinearAlgebra.det', {[1.0 2.0; 3.0 4.0]})

ans =

    -2

Unique environments

Set the 'shared' flag to false in order to evaluate each Julia call in a separate namespace on the Julia server:

% Restart the server, setting 'shared' to false
>> jlcall('LinearAlgebra.norm', {[3.0; 4.0]}, 'modules', {'LinearAlgebra'}, 'restart', true, 'shared', false)

ans =

     5

% This call now errors, despite the above command loading the LinearAlgebra module, as LinearAlgebra.norm is evaluated in a new namespace
>> jlcall('LinearAlgebra.norm', {[3.0; 4.0]}, 'shared', false)
ERROR: LoadError: UndefVarError: LinearAlgebra not defined
Stacktrace:
 ...

Unique Julia instances

Instead of running Julia code on a persistent Julia server, unique Julia instances can be launched for each call to jlcall.m by passing the 'server' flag with value false.

Note: this may cause significant overhead when repeatedly calling jlcall.m due to Julia package precompilation and loading:

>> tic; jlcall('x -> sum(abs2, x)', {1:5}, 'server', false); toc
Elapsed time is 4.181178 seconds. % call unique Julia instance

>> tic; jlcall('x -> sum(abs2, x)', {1:5}, 'restart', true); toc
Elapsed time is 5.046929 seconds. % re-initialize Julia server

>> tic; jlcall('x -> sum(abs2, x)', {1:5}); toc
Elapsed time is 0.267088 seconds. % call server; significantly faster

Loading code from a local project

Code from a local Julia project can be loaded and called:

>> jlcall('MyProject.my_function', args, kwargs, ...
    'project', '/path/to/MyProject', ...
    'modules', {'MyProject'})

Note: the value of the 'project' flag is simply added to the Julia LOAD_PATH; it is the user’s responsibility to ensure that the project’s dependencies have been installed.

Loading setup code

Julia functions may require or return types which cannot be directly passed from or loaded into MATLAB. For example, suppose one would like to query Base.VERSION. Naively calling jlcall('() -> Base.VERSION') would fail, as typeof(Base.VERSION) is not a String but a VersionNumber.

One possible remedy is to define a wrapper function in a Julia script:

# setup.jl
julia_version() = string(Base.VERSION)

Then, use the 'setup' flag to pass the above script to jlcall.m:

>> jlcall('julia_version', 'setup', '/path/to/setup.jl')

ans =

    '1.6.1'

In this case, jlcall('() -> string(Base.VERSION)') would work just as well. In general, however, interfacing with complex Julia libraries using MATLAB types may be nontrivial, and the 'setup' flag allows for the execution of arbitrary setup code.

Note: the setup script is loaded into the global scope using include; when using persistent environments, symbols defined in the setup script will be available on subsequent calls to jlcall.m.

Handling Julia outputs

Output(s) from Julia are returned using the MATLAB cell array varargout, MATLAB’s variable-length list of output arguments. A helper function JuliaFromMATLAB.matlabify is used to convert Julia values into MATLAB-compatible values. Specifically, the following rules are used to populate varargout with the Julia output y:

  1. If y::Nothing, then varargout = {} and no outputs are returned to MATLAB
  2. If y::Tuple, then length(y) outputs are returned, with varargout{i} given by matlabify(y[i])
  3. Otherwise, one output is returned with varargout{1} given by matlabify(y)

The following matlabify methods are defined by default:

matlabify(x) = x # default fallback
matlabify(::Union{Nothing, Missing}) = zeros(0,0) # equivalent to MATLAB's []
matlabify(x::Symbol) = string(x)
matlabify(xs::Tuple) = Any[matlabify(x) for x in xs] # matlabify values
matlabify(xs::Union{<:AbstractDict, <:NamedTuple, <:Base.Iterators.Pairs}) = Dict{String, Any}(string(k) => matlabify(v) for (k, v) in pairs(xs)) # convert keys to strings and matlabify values

Note: MATLAB cell and struct types correspond to Array{Any} and Dict{String, Any} in Julia.

Conversion via matlabify can easily be extended to additional types. Returning to the example from the above section, we can define a matlabify method for Base.VersionNumber:

# setup.jl
JuliaFromMATLAB.matlabify(v::Base.VersionNumber) = string(v)

Now, the return type will be automatically converted:

>> jlcall('() -> Base.VERSION', 'setup', '/path/to/setup.jl')

ans =

    '1.6.1'

Communication between MATLAB and Julia

The .mat files which are used to store MATLAB input arguments and Julia output arguments can be configured with the 'infile' and 'outfile' flags, respectively. Pointing these files to a ram-backed file system is recommended when possible (for example, the /tmp folder on Linux is usually ram-backed), as read/write speed will likely improve. This is now the default; 'infile' and 'outfile' are created via the MATLAB tempname function (thanks to @mauro3 for this tip).

Performance

MATLAB inputs and Julia ouputs are passed back and forth between MATLAB and the DaemonMode.jl server by writing to temporary .mat files. The location of these files can be configured with the 'infile' and 'outfile' flags, respectively. Pointing these files to a ram-backed file system is recommended when possible (for example, the /tmp folder on Linux is usually ram-backed), as read/write speed will likely improve. This is now the default; 'infile' and 'outfile' are created via the MATLAB tempname function (thanks to @mauro3 for this tip).

Nevertheless, this naturally leads to some overhead when calling Julia, particularly when the MATLAB inputs and/or Julia outputs have large memory footprints. It is therefore not recommended to use jlcall.m in performance critical loops.

MATLAB and Julia version compatibility

This package has been tested on a variety of MATLAB versions. However, for some versions of Julia and MATLAB, supported versions of external libraries may clash. For example, running jlcall.m using Julia v1.6.1 and MATLAB R2015b gives the following error:

>> jlcall

ERROR: Unable to load dependent library ~/.local/julia-1.6.1/bin/../lib/julia/libjulia-internal.so.1

Message: /usr/local/MATLAB/R2015b/sys/os/glnxa64/libstdc++.so.6: version `GLIBCXX_3.4.20' not found (required by ~/.local/julia-1.6.1/bin/../lib/julia/libjulia-internal.so.1)

This error results due to a clash of supported libstdc++ versions, and does not occur when using e.g. Julia v1.5.4 with MATLAB R2015b, or Julia v1.6.1 with MATLAB R2020b.

If you encounter this issue, see the Julia and MATLAB documentation for information on mutually supported external libraries.

30 Likes

I’m fairly sure this can be avoided completely because you run your Julia code in a separate operating system process. I haven’t used matlab for a long time, but IIRC the problem here is because matlab likes to set environment variables LD_LIBRARY_PATH (and possibly LD_PRELOAD?) which breaks the invocation of user executables with matlab’s system function (sigh… matlab :man_facepalming:)

This problem is described here: Why does Matlab set a custom LD_LIBRARY_PATH when execting "system" on Linux? - MATLAB Answers - MATLAB Central

The solution is to unset the necessary environment variables, or otherwise undo whatever crimes matlab has committed against your OS environment.

4 Likes

Thanks for the tip! I think you must be right. It didn’t seem right to me that Julia would fail to start up from within a MATLAB system call, yet work fine in a bash shell, but I was unsure how to debug the problem. I will look into this some more :+1:t2:

Nice! Maybe add to the Performance section that using a ram-backed file system, such as is usually the case in Linux for /tmp (it’s a tmpfs), would probably help improve read/write to the temporary .mat files.

1 Like

Thanks @jondeuce !

Yes, I was looking for some time into this and came to about the same conclusions: command-line (non-API) and MAT.jl. My code uses Java-streams and a simple julia -iq call, not requiring DaemonMode.jl.

This is one of my very first Julia codes, so no bells and whistles as your implementation.
Also, my call pattern is a bit different,

 jl.call('[a, m] = addmult', 2, 3);
 a+m
 ans =
     11

I am curious to see how your matlabify type translation and other features chime in.

jlcall.jl
#=
jlcall.jl
Matlab-Julia interface, called from jlcall.m
=#

using MAT

function jlcall(iofile)
    # read call arguments from iofile
    matvars = matread(iofile)
    outvars = matvars["outvars"]
    fun = matvars["fun"]
    sfun = Symbol(fun)
    inputs = matvars["varargin"]    
    # if necessary, load function, run Julia
    if true #!(@isdefined sfun)
        funfile = matvars["fun"] * ".jl"
        println("...loading $funfile")
        include(funfile)
    end
    results = @eval $sfun($inputs...) 
    # save output arguments to iofile
    d = Dict{String, Any}()
    for i = 1:length(outvars)
        d[outvars[i]] = results[i]
    end
    matwrite(iofile, d)
    println("OK")
end
jlcall.m
function jlcall(outvars_fun, varargin)
%JLCALL Execute Julia function from Matlab
% jlcall('[outvar1, outvar2, ...] = fun', var1, var2, ...)
% fun is a Julia function, built-in or defined in <fun>.jl
%
% Example:
%
% >> jlcall('[a, m] = addmult', 2, 3)
% >> a+m
% ans =
%     11

outvars_fun = split(outvars_fun, {'[',']','=',',',' '});
outvars_fun(cellfun('isempty',outvars_fun)) = [];
outvars = outvars_fun(1:end-1);
fun = outvars_fun{end};

iofile = [tempname, '.mat'];
save(iofile, 'outvars', 'fun', 'varargin');
system(sprintf('julia jlcall.jl %s', strrep(iofile, '\', '/')));
load(iofile, outvars{:});
system(sprintf('rm %s', iofile));
end

Thanks, good suggestion! Updating now.

Interesting! Yes, it was actually your post I referenced at the start of this post that led to me creating this package.

I’m not familiar with Java-streams. The main reason I used DaemonMode was indeed for the bells and whistles. Particularly the ability to easily run expressions in anonymous modules.

The matlabify type translation is really quite simple, I’m sure you could easily incorporate a similar method. The main purpose of matlabify is just to have something that users can extend to custom types.

Great work! I am considering translating a transition to Julia and the JuliaFromMATLAB could be useful for doing that.

Also, I am glad that you consider DaemonMode useful for your package. It is nice to see how packages complement each others in the Julia ecosystem.

2 Likes