ソケットを使ってLua/LuaTeX文書からMaximaを呼び出す


ネタ元:マークシート選択式問題における数式処理の活用

上記のスライドによると、LuaTeXのLuaの機能 (io.popen) を使って数式処理システムMaximaを呼び出す時に、数式ごとにMaximaを起動していると遅いそうです。

毎度Maximaを起動するのが遅いのであれば、文書処理の間はMaximaを立ち上げっぱなしにして、何らかのプロセス間通信でMaximaとやりとりする、という方法が考えられます。

Maximaはプロセス間通信の方法として、標準入出力のほかにソケットに対応しています。また、LuaTeXにはLuaSocketという、Luaでソケットを扱えるライブラリーが付属しています。

ということで、LuaSocketでMaximaと通信してみます。

ncで実験してみる

いきなりLuaSocketを使う前に、 nc コマンドを使ってMaximaのソケット通信機能を試してみます。

サーバーはMaximaを使いたい側が用意します。Maximaの起動時にポート番号を教えると、Maximaがクライアントとしてそこに接続する、という形になります。

実験では、12345番ポートを使ってみます。

ターミナル1
$ nc -l 12345

nc を起動したのとは別のターミナルでMaximaを立ち上げます。その際、-s オプションでポート番号を指定します。

ターミナル2
$ maxima --very-quiet -s 12345
Connecting Maxima to server on port 12345

Maximaがソケットに接続すると、ソケット経由で pid=<プロセスID> という文字列が送られてきます。

あとは普通にやりとりします。

ターミナル1
[Maximaからの出力]pid=51844
[Maximaへの入力 ]tex1(expand((1+x)^5));
[Maximaからの出力]                       x^5+5\,x^4+10\,x^3+10\,x^2+5\,x+1
[Maximaへの入力 ]tex1(diff(sin(x)*cos(x^2), x));
[Maximaからの出力]                    \cos x\,\cos x^2-2\,x\,\sin x\,\sin x^2

LuaSocketでやってみる

あとは、同様のやりとりをLuaSocketで実装します。LuaSocketの説明はマニュアルを参照してください。

Lua標準で外部コマンドを起動する方法は os.executeio.popen がありますが、前者は実行するコマンドが完了するまでLua側に制御が帰ってきません。それでは困るので、Maximaコマンドの起動には io.popen を使うことにします。

local socket = require "socket"
local server = assert(socket.bind("*", 0))
local ip, port = server:getsockname()
print("IP:", ip)
print("Port:", port)
local maxima = assert(io.popen(string.format("maxima --very-quiet -s %d", port)))
local client = assert(server:accept())
print("Connection accepted")
print("Maxima stdout:", maxima:read("*l")) -- "Connecting Maxima to server on port XXXXX"
client:settimeout(10)
assert(client:receive("*l")) -- "pid=*****"

client:send("tex1(expand((x+3)^2));\n")
local result = assert(client:receive("*l"))
print("Received", result)

client:send("quit();\n")

print("Closing socket", client:close())
print("Closing pipe", maxima:close())

assert(client:receive("*l")) の行までが準備(Maximaの起動と通信の確立)で、 client:sendclient:receive の部分が個々の数式に関するMaximaとのやりとりです。

最後に quit(); を送ってMaximaを自発的に終了させていますが、環境によってはこれがなくても動きます。Linuxでは quit(); なしで切断しようとするとMaximaがセグフォを起こしました。

LuaScoket等のエラーチェックは、雑に assert で済ませています。

このコードを標準のLuaインタープリターで試すにはLuaSocketが必要です。TeX環境が手元のPCに整っているという方なら、LuaTeXのLuaインタープリターである texlua を使うと別途LuaSocketを用意する必要がないので楽です。

Lua(La)TeX文書に埋め込んでみる

あとはLuaTeX文書にこのコードを埋め込めばOKです。

ただし、TeXファイル中にLuaコードをがっつり書くのはだるいので、LuaコードはTeXとは別のファイルに書きます。

luatex-maxima.lua
local socket = require "socket"
local meta = {}
meta.__index = meta

-- 新しくMaximaセッションを起動する
local function new()
  local server = assert(socket.bind("*", 0))
  local ip, port = server:getsockname()
  print(ip, port)
  local maxima = assert(io.popen(string.format("maxima --very-quiet -s %d", port)))
  local client = assert(server:accept())
  print("Connection accepted")
  -- maxima:read("*l") -- "Connecting Maxima to server on port XXXXX"
  client:settimeout(10)
  assert(client:receive("*l")) -- "pid=*****"
  return setmetatable({_server = server, _client = client, _process = maxima}, meta)
end

-- Maximaセッションを使ってコマンドを実行する
function meta:run(command)
  command = command:gsub("\n*$", "")
  self._client:send(command..";\n")
  local result = assert(self._client:receive("*l"))
  print("Received", result)
  return (result:gsub("%s", " "))
end

-- Maximaセッションを閉じる
function meta:close()
  self._client:send("quit();\n")
  self._client:close()
  self._process:close()
  self._server:close()
end
return {
  new = new,
}

LuaLaTeX文書からは dofile で上述Luaコードを実行し、Maximaセッションを起動します。

luatex-maxima-test.tex
\documentclass{article}
\directlua{
  maxima = dofile("luatex-maxima.lua")
  session = maxima:new()
}
\newcommand\maxima[1]{\directlua{tex.print(session:run([[tex1(#1)]]))}}
\begin{document}
\[\maxima{expand((x+3)^2)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{diff((x+3)^2,x)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{expand((x+3)^2)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x^3+3)^50,x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\[\maxima{diff((x+3)^(50),x)}\]
\directlua{session:close()}
\end{document}

あとは、 lualatex --shell-escape luatex-maxima-test.tex という感じで処理します。

処理時間の比較

比較用に、毎回Maximaを立ち上げる版の luatex-maxima-nosocket.lua を以下の内容で用意しておきます。

luatex-maxima-nosocket.lua
local meta = {}
meta.__index = meta
local function new()
  return setmetatable({}, meta)
end
function meta:run(command)
  command = command:gsub("\n*$", "")
  local maxima = assert(io.popen(string.format("echo '%s;' | maxima --very-quiet", command)))
  local result = maxima:read("*a")
  maxima:close()
  result = (result:gsub("%s", " "))
  print("Received", result)
  return result
end
function meta:close()
end
return {
  new = new,
}

こちらを使う場合は、 luatex-maxima-test.texdofile("luatex-maxima.lua")dofile("luatex-maxima-nosocket.lua") に書き換えます。

私のMac環境(High Sierra / TeX Live 2018 / Maxima 5.41.0)で

$ time lualatex --shell-escape luatex-maxima-test.tex

を実行したところ、毎回Maximaを起動する版の処理時間は11.4秒、ソケットで通信する版は3.8秒でした。Maximaの起動回数を減らすことの効果はやはり大きいようです。数式の量が多ければ差はもっと開くかもしれません。

おまけ:popenによる双方向通信

popen関数は通常、「プロセスの標準出力を読み取る」または「プロセスの標準入力に書き込む」のどちらかの動作しかできません。

これに対し、FreeBSDのlibcではpopenの第二引数に "r+" を指定することで双方向パイプを開けるようです。これを使うと、ソケットを使わなくても次のようにMaximaとやりとりできます:

local maxima = assert(io.popen("maxima --very-quiet", "r+"))
maxima:setvbuf("line")

maxima:write("tex1((1+x)^2);\n")
print("Maxima stdout:", maxima:read("*l"))

maxima:write("tex1(expand((x+3)^2));\n")
print("Maxima stdout:", maxima:read("*l"))

maxima:close()

しかし、popenで双方向通信できるのはFreeBSD系(FreeBSDとMac)だけ(Linux, Windowsでは対応していない)のようなので、文書がMac専用でいいというのでもない限り、Maximaとのやりとりにはソケットを使うのが良いでしょう。