22

What I will describe below is not yet a problem but will appear when TeX Live 2019 is released and LuaTeX will be upgraded to 1.09. MikTeX users might already experience this issue. It also affects users of Mac OS X, because the random number generator of their C standard library is different from the one of glibc.


When I typeset one of the graphdrawing examples from the TikZ manual, it appears mirrored between the LuaTeX versions 1.07 (TeX Live 2018) and 1.09 (TeX Live 2019). Why is that happening?

\documentclass{article}
\usepackage{tikz}
\usetikzlibrary{decorations.pathmorphing,graphs,graphdrawing}
\usegdlibrary{force}
\begin{document}
\tikz [spring electrical layout, node distance=1.3cm,
       every edge/.style={
         decoration={coil, aspect=-.5, post length=1mm,
                     segment length=1mm, pre length=2mm},
         decorate, draw}]
{
  \foreach \i in {1,...,6}
    \node (node \i) [fill=blue!50, text=white, circle] {\i};

  \draw (node 1) edge (node 2)
        (node 2) edge (node 3)
                 edge (node 4)
        (node 3) edge (node 4)
                 edge (node 5)
                 edge (node 6);
}
\end{document}

On the left is LuaTeX 1.07, on the right LuaTeX 1.09:

 

Henri Menke
  • 109,596

1 Answers1

21

The problem with random numbers

The issue you are experiencing stems from the fact that the Lua version which is bundled with LuaTeX was upgraded. LuaTeX 1.07 uses Lua 5.2, whereas LuaTeX 1.09 uses Lua 5.3. This has some peculiar implications.

The force-based graphdrawing algorithms set up the vertices in a random configuration to start with. Then the system is annealed to find the optimal configuration of the nodes. To avoid getting stuck in a local minimum or at a saddle point, small random forces are added in each iteration.

As you may have noticed in the previous paragraph, the word “random” appears a couple of times. That means that the algorithm is quite sensitive to the choice of random numbers. However, between versions 5.2 and 5.3, Lua changed its default random number generator (RNG). While Lua 5.2 used the C standard library rand() function, Lua 5.3 uses the POSIX random() function. Therefore you get entirely different random numbers even with the same seed. I believe this issue is localized to POSIX-compatible platforms, like Linux or Mac OS X.

$ lua5.2 -e "math.randomseed(42); print(math.random())"
0.32996420763897
$ lua5.3 -e "math.randomseed(42); print(math.random())"
0.32996420748532

This does not mean that your graphs will be entirely different because the distribution of the random numbers stays the same (uniform distribution), only their sequence is different. That is also why the graphs still look so similar and only appear mirrored.

Luckily this issue is entirely localized to the spring electrical layout, because no other graph drawing algorithm uses random numbers.

Mitigations

Implementation of glibc's rand() in pure Lua

Another option would be to reimplement the Lua 5.2 random number generator in Lua 5.3. This is of course not a good option and should be avoided, but in principle it is possible.

The pure C and Lua implementations can be found in my GitHub Gist.

\documentclass{article}
\usepackage{luacode}
\begin{luacode*}
local ok, bit32 = pcall(require, "bit32")
if not ok then
    ok, bit32 = pcall(require, "bit")
end
if not ok then
    error("No bitwise operations available")
end

if _VERSION == "Lua 5.3" or type(jit) == "table" then
    local add -- https://stackoverflow.com/a/27030128
    add = function(a,b)
        if (bit32.bxor(b,0x0) == 0) then
            return a
        end
        return add(bit32.bxor(a,b), bit32.lshift(bit32.band(a,b),1))
    end

    local r = {}

    local ffffffff = bit32.bnot(0x00000000)
    local RAND_MAX = 2147483647

    local i = 0
    local rand = function()
        i = i % 344 + 1
        r[i] = add(r[(i - 32 + 344) % 344 + 1], r[(i - 4 + 344) % 344 + 1])
        local r = bit32.rshift(bit32.band(r[i], ffffffff), 1)
        return r
    end

    local srand = function(seed)
        -- can't seed with 0
        if seed == 0 then
            seed = 1
        end

        r[1] = seed
        for i = 2, 31 do
            --[[ (from stdlib/random_r.c) This does:
                    r[i] = (16807 * r[i - 1]) % 2147483647;
                but avoids overflowing 31 bits. ]]
            local hi = math.floor(r[i - 1] / 127773)
            local lo = r[i - 1] % 127773
            r[i] = bit32.band(16807 * lo - 2836 * hi, ffffffff)
        end
        for i = 32, 34 do
            r[i] = r[i-31]
        end
        for i = 35, 344 do
            r[i] = add(r[i-31], r[i-3])
        end
    end

    function math.random(l,u)
        local r = rand() / RAND_MAX
        if l and u then -- lower and upper limits
            assert(l <= u, "interval is empty")
            return math.floor(r*(u-l+1)) + l
        elseif l then -- only upper limit
            assert(1.0 <= u, "interval is empty")
            return math.floor(r*u) + 1.0
        else -- no arguments
            return r
        end
    end

    function math.randomseed(seed)
        if seed < 0 then
            srand(math.floor(seed))
        else
            srand(math.ceil(seed))
        end            
        rand() -- discard first value to avoid undesirable correlations
    end

    -- the default seed is 1
    srand(1)
end
\end{luacode*}
\usepackage{tikz}
\usetikzlibrary{decorations.pathmorphing,graphs,graphdrawing}
\usegdlibrary{force}
\begin{document}
\tikz [spring electrical layout, node distance=1.3cm,
       every edge/.style={
         decoration={coil, aspect=-.5, post length=1mm,
                     segment length=1mm, pre length=2mm},
         decorate, draw}]
{
  \foreach \i in {1,...,6}
    \node (node \i) [fill=blue!50, text=white, circle] {\i};

  \draw (node 1) edge (node 2)
        (node 2) edge (node 3)
                 edge (node 4)
        (node 3) edge (node 4)
                 edge (node 5)
                 edge (node 6);
}
\end{document}

This image will still not be exactly the same. The coil between 3 and 4 will have a different number of windings but I have no idea where this comes from. These might be artifacts of numerical imprecision due to Lua 5.3 introducing an integer datatype.

Foreign Function Interface

Another alternative is to redefine the random number generator using the rand() and srand() functions by accessing them through the Foreign Function Interface (FFI). This however requires --shell-escape. To save some space I only present the chunk that would go in the luacode environment of the previous example instead.

local ffi = assert(require("ffi"))

ffi.cdef[[
int rand();
void srand(unsigned seed);
]]

if _VERSION == "Lua 5.3" then
    local RAND_MAX = 2^31 - 1

    function math.random(l,u)
        local r = ffi.C.rand() / RAND_MAX
        if l and u then -- lower and upper limits
            assert(l <= u, "interval is empty")
            return math.floor(r*(u-l+1)) + l
        elseif l then -- only upper limit
            assert(1.0 <= u, "interval is empty")
            return math.floor(r*u) + 1.0
        else -- no arguments
            return r
        end
    end

    function math.randomseed(seed)
        if seed < 0 then
            ffi.C.srand(math.floor(seed))
        else
            ffi.C.srand(math.ceil(seed))
        end            
        ffi.C.rand() -- discard first value to avoid undesirable correlations
    end
end

Twiddling the numbers

If you look at the source code for how Lua 5.2 and Lua 5.3 obtain the random numbers, you'll see that they look pretty similar:

// Lua 5.2
lua_Number r = (lua_Number)(rand()%RAND_MAX) / (lua_Number)RAND_MAX;
// Lua 5.3
double r = (double)l_rand() * (1.0 / ((double)L_RANDMAX + 1.0));

If rand() and l_rand() returned the same numbers, then the result would only differ by the factor they are multiplied by. In other words, you could obtain the result of Lua 5.2 from the one in Lua 5.3 by simple multiplication:

r52 = r53 * (RAND_MAX + 1.0) / RAND_MAX

Then we can simply hook into the math.random function and twiddle the numbers like that. To save some space I only present the chunk that would go in the luacode environment of the first example instead.

if _VERSION == "Lua 5.3" then
    local RAND_MAX = 2^31 - 1
    local random = math.random
    function math.random(l,u)
        local r = random() * (RAND_MAX + 1.0) / RAND_MAX
        if l and u then -- lower and upper limits
            assert(l <= u, "interval is empty")
            return math.floor(r*(u-l+1)) + l
        elseif l then -- only upper limit
            assert(1.0 <= u, "interval is empty")
            return math.floor(r*u) + 1.0
        else -- no arguments
            return r
        end
    end
end

On my platform this actually works because in glibc, rand() is only an alias for random(), but I don't think this can be relied on, especially on the BSDs.

Henri Menke
  • 109,596
  • In lua 5.3, posix random() is used if defined LUA_USE_LINUX or defined LUA_USE_MACOSX. Otherwise rand() is used. Here on windows, I obtain LuaTeX 1.07 result in this example by LuaTeX 1.09 with lua-5.3. I changed an argument x in math.random as math.floor(x) to avoid an error in the present base package. – Akira Kakuto Jan 04 '19 at 04:44
  • 1
    @AkiraKakuto That's a good point. On the BSDs it will probably also look different because they implement a different rand() in the standard library. Here for example OpenBSD: https://github.com/openbsd/src/blob/e1e1437f5b3e43941b5cda7022143c7b511af7d6/lib/libc/stdlib/rand.c#L34-L56 – Henri Menke Jan 04 '19 at 05:02
  • I have updated pgf today. I obtain luatex 1.09 result shown here in luatex 1.09 on windows, except that 5 and 6 are reversed. – Akira Kakuto Jan 05 '19 at 23:23
  • If I use on windows #define l_rand() genrand_int31() #define l_srand(x) init_genrand(abs(x-1)) #define L_RANDMAX 0x7fffffff by using mt19937ar.c in (http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/MT2002/emt19937ar.html), the 1.09 result became the same as shown above. The tikz-feynman example also became OK. The change of seed was necessary to obtain desired figure for the tikz-feynman example. – Akira Kakuto Jan 06 '19 at 05:22
  • Now that XeTeX will support \(pdf)uniformdeviate primitive as other engines, it could be possible to use this RNG which certainly has smaller seed space than the one from glibc, but this does not seem very important here for graph drawing algorithms. LaTeX3 has a (int and float) wrapper which addresses some issues with the way the engine primitive produces random integers (based on Knuth MetaPost RNG) and xint has another wrapper for the same aim. As per xint, this is still WIP as I have not finalized my views on this. My algorithm for boosting the uniformity is not the same as Bruno's. –  Jan 09 '19 at 13:20
  • @jfbu I had a look and this looks promising because that will be completely platform independent. Unfortunately, LuaTeX is lacking the \setrandomseed primitive at the Lua end. – Henri Menke Jan 09 '19 at 23:18
  • @HenriMenke I don't understand about \setrandomseed, but I don't know "Lua end". What kind of random numbers does the graph drawing library need? random floats in 0..1 ? The MetaPost RNG is based on same kind of linear recurrences as in your code, but with a smaller state space. The low bits have some peculiarities and the pdfTeX pdfuniformdeviate which simply rescales to an integer range inherits these low bits peculiarities as is described in 6.7 (WIP) \xintUniformDeviate from xint.pdf and better in 34 l3fp-random Implementation chapter of source3.pdf. –  Jan 10 '19 at 10:53
  • The l3fp-random for integers has two branches: "fast" for the range R<2^17 and "slower" for larger integer ranges. Random floats are produced 4 digits per 4 digits from the "fast" branch. xint has a different algorithm which is intermediate in speed (for rand ints). It generates random floats 8 digits per 8 digits and you can see the code for 8 digits at 5.58 (WIP) \XINT_eightrandomdigits in sourcexint.pdf, which makes 5 calls to the engine primitive. (it could do with 2; due to the \numexpr it costs 13 times as much as a single call to primitive). –  Jan 10 '19 at 11:04
  • ("digits" means "decimal digits" in my comments) –  Jan 10 '19 at 11:26