Physics Lab. 2024のロゴ

アドカレ|24日目

ただしい高速LaTeX論

概要

ただしく[1]LATEXを高速化しましょう。

想定読者

LATEXを高速化したい人であって

  • Typstの導入コストに耐えられない人
  • LATEXの既存資産を活用したい人[2]
  • LATEXへの偏愛がある人
  • 意味を孕んだ複雑性が好きな人

想定環境

  • TeX Live導入済
  • macOS[3]
  • Visual Studio CodeLaTeX Workshopを使って書いている[4]

ステップ一覧

問題点 対策 新規性
タイプセットを何度も何度も latexmkの導入 x
そもそもタイプセットが遅い uplatexの使用 x
パッケージ読み込みが重い mylatexformatの導入 o
.texファイルがでかい subfilesの導入 x

第三列から明らかなように、当記事の主題はmylatexformatの適切な使用法であるが、周辺技術についても記述しておく。

latexmkの導入

TEXのあるあるとして参照が切れないように複数回タイプセットがあるが、ちゃんと設定しないと変更がない中間ファイルも複数回生成される。bibtexやらmakeindexやらが絡むとなおさらである。latexmkは中間ファイルを管理・監視して、必要最小限のタイプセットで.pdfファイルを生成してくれる。

インストール

TeX Liveに同梱なので省略。

latexmkrcの設定

探せばいくらでも転がっているが、例。

#!/usr/bin/env perl

# カレントディレクトリ変更
$do_cd = 1;

# uplatexの呼び出し(後で変わる)
$pdf_mode = 3;
$latex = 'uplatex -synctex=1 -file-line-error -halt-on-error %O %S';
$dvipdf = 'dvipdfmx %O -o %D %S';
$max_repeat = 5;

# bibtex系
$bibtex_use=2;
$bibtex = 'upbibtex %O %S';
$biber = 'biber --bblencoding=utf8 -u -U --output_safechars %O %S';

# index
$makeindex = 'upmendex %O -o %D %S -s jpbase';

# ヴューワ
$dvi_previewer = "open %S";
$pdf_previewer = "open %S";

# 出力フォルダ指定
$out_dir = ".";
# 中間ファイルを別フォルダに隠しておける
$emulate_aux = 1;
$aux_dir = ".tex_intermediates";

# 中間ファイル登録
$clean_ext="$clean_ext run.xml";

latexmkの設定[雑多な記録]を参考とした。大まかな要素はそちらを参照していただきたい。以下は補足。

# カレントディレクトリ変更
$do_cd = 1;

latexmkがタイプセット対象の.texファイルの位置にcdしてくれる。TEXは変な文字を嫌う(~など)ので、こうしておくとファイルパス中のダメ文字を拾われずに済む。 顕著な例として、iCloud Driveの実体は/Users/<ユーザ名>/Library/Mobile Documents/com~apple~CloudDocs/にあるため、これを入れないと確定で失敗する。

# uplatexの呼び出し
$pdf_mode = 3;
$latex = 'uplatex -synctex=1 -file-line-error -halt-on-error %O %S';
$dvipdf = 'dvipdfmx %O -o %D %S';
$max_repeat = 5;

$latexで指定したオプションは以下の意図がある。

  • -synctex=1 SyncTeX機能が有効になり、.synctex.gzファイルが生成される。これがあると.pdfヴューワと.texヴューワで対応する位置を相互に移れる。LaTeX Workshop下でも有効なのでつけておくと便利。
  • -file-line-error エラーメッセージが<ファイル名>:<行番号>:<エラー内容>になる。LaTeX Workshopにエラーを認識してもらうのに必要。
  • -halt-on-error エラーを吐いたら停止する。というか停止せず続行するってなんなんだろうか。-interaction=nonstopmode使ってる人の気が知れない。
# bibtex系
$bibtex_use=2;
$bibtex = 'upbibtex %O %S';
$biber = 'biber --bblencoding=utf8 -u -U --output_safechars %O %S';

$bibtex_use=2;を指定すると適宜.bibファイルから適宜.bblファイルを生成する。つまり.bblファイルが中間ファイル扱いになる。自前の.bblファイルを使いたい人はファイルが消されないよう$bibtex_use=<0 or 1 or 1.5>;にしておくべきだが、まぁそんなことは滅多にあるまい。

# index
$makeindex = 'upmendex %O -o %D %S -s jpbase';

upmendexを用いる。これは索引ツール比較[TEX Wiki]によればuplatexで利用可能な最も上位互換なツールのため。 -s jpbaseで索引のフォーマットをjpbase[5]に指定している。本当は.texファイル側でフォーマットを指定できるべきだし、実際できるのだが、それをやるには-shell-escapeというやや危ない橋を渡ることになるのでとりあえず一番振る舞いの良いこいつで固定。このあとやるlatexmkrc改造と似たようなことをすれば変更可能になるはず。

# ヴューワ
$dvi_previewer = "open %S";
$pdf_previewer = "open %S";

latexmk-vオプションを入れた時に使うヴューワを指定するが、LaTeX Workshopを使っているならそんな機会はまずないだろう。

# 出力フォルダ指定
$out_dir = ".";
# 中間ファイルを別フォルダに隠しておける
$emulate_aux = 1;
$aux_dir = ".tex_intermediates";

出力フォルダの.はカレントディレクトリを意味し、cdしているから要するに.texファイルのある位置を意味する。 中間ファイルの出力先を変更する機能はTEX系のツールには存在しないのだが、latexmkが適宜動かしてくれる。.tex_intermediatesのように、隠しフォルダに入れておくとメインのフォルダがスッキリ[6]して良い。

# 中間ファイル登録
$clean_ext="$clean_ext run.xml";

LaTeX Workshopがプロセス監視の結果として.run.xmlを生成するので、こいつを消去対象の拡張子に加えておく。

LaTeX Workshopの設定

中間ファイルの管理を全部latexmk側に押し付けると、LaTeX Workshopの設定はこのぐらいでいい。

{
    // ファイル保存時の実行「レシピ」
    "latex-workshop.latex.recipe.default": "latexmk",
    "latex-workshop.latex.recipes": [
        {
            // latexmkを叩くだけのレシピ
            "name": "latexmk",
            "tools": ["latexmk"]
        }
    ],
    // レシピに使われるパーツ
    "latex-workshop.latex.tools": [
        {
            "name": "latexmk",
            "command": "latexmk",
            "args": [
                // "-time"で実行時間を表示してくれる。
                "-time",
                "%DOC%"
            ],
        }
    ],
    // あとはお好みで
    "latex-workshop.intellisense.package.enabled": true,
    "latex-workshop.latex.outDir": "",

    "latex-workshop.synctex.afterBuild.enabled": true,
    "latex-workshop.view.pdf.viewer": "tab",
    "latex-workshop.latex.autoBuild.cleanAndRetry.enabled": false,
}

VScodeでLaTex環境を整える際のあれこれを参考にした。

用法

これもいくらでも転がっているが、頻用するのは

latexmk <.texファイル名> # タイプセット
latexmk -c # 中間ファイルだけ削除
latexmk -C # 全出力ファイル削除(.pdfも消える)

ぐらいか。

uplatexの使用

lualatexはスクリプトとかかけて便利ですが、普通に遅いのでuplatex使いましょう。ただしさ[7]は速さを必ずしも意味しないので。

時間測定

後でやるかも?

mylatexformatの導入

大規模パッケージを複数入れたりすると、タイプセットのオーバーヘッドがかなりの時間を占めるようになってくる。 実はTEXにはフォーマットファイルを作っておく機能(イニシャルモード)があるのだが、普通にやると\begin{document}以降の中身までフォーマット化してしまう。 mylatexformatパッケージはTEXのイニシャルモードをハックして、プリアンブル部分だけのフォーマットファイルを作成してくれる。

latexmkrcの改造

latexmkrcの設定

# uplatexの呼び出し
$pdf_mode = 3;
$latex = 'uplatex -synctex=1 -file-line-error -halt-on-error %O %S';
$dvipdf = 'dvipdfmx %O -o %D %S';
$max_repeat = 5;

# fmtlatexの呼び出し
$pdf_mode = 3;
$latex = 'internal fmtlatex uplatex %Z %Y %A %S %R -synctex=1 -file-line-error -halt-on-error %O';
$dvipdf = 'dvipdfmx %O -o %D %S';
$max_repeat = 5;

# 作業パス
my $comdir=$ENV{HOME};
my $comname=".latexmk";
my $pwd=`pwd`;
chomp $pwd;

# fmtlatex メインルーチン
{
    # 拡張子を登録
    $clean_ext="$clean_ext fmt";
    my $initial = 1;

    sub fmtlatex {
        # 引数読込
        my ($engine, $outpath, $auxpath, $basename, $texname, $jobname, @args) = @_;
        my $options = join(' ', @args);

        # 初回実行時
        if ($initial == 1){
            $initial = 0;
            # フォーマット生成フラグ
            my $flag = 0;
            print "fmtlatex: checking if the preamble changed...\n";
            if (&check_preamble_change($auxpath,$jobname,$texname) == 0){
                print "fmtlatex: the preamble is not changed.\n";
                print "fmtlatex: checking if the common fmt file is owned...\n";
                if (&check_com_owned("$pwd/$texname") == 0){
                    print "fmtlatex: the common fmt file is not owned.\n";
                    $flag = 1;
                }else{
                    print "fmtlatex: the common fmt file is owned.\n";
                }
            }else{
                print "fmtlatex: the preamble is changed.\n";
                $flag = 1;
            }
            if ($flag == 1){
                print "rewriting the common fmt file in ini mode...\n";
                # フォーマット生成
                my $iniret=Run_subst("$engine -ini $options -output-directory=\"$comdir\" -jobname=\"$comname\" \\\&$engine mylatexformat.ltx $texname");
                if($iniret == 0){
                    print "fmtlatex: the common fmt file rewrited. saving preamble...\n";
                    &memorize_preamble_change($auxpath,$jobname);
                    &hold_com("$pwd/$texname");
                }else{
                    print "fmtlatex: failed to rewrite the common fmt file.\n";
                    &forget_preamble_change($auxpath,$jobname);
                    &throw_com("$pwd/$texname");
                    return $iniret;
                }
            }else{
                print "keep the common fmt file.\n";
                &forget_preamble_change($auxpath,$jobname);
            }
        }
        print "fmtlatex: the common fmt file is ready, so running normal latex... \n";
        # 通常のタイプセット
        my $finalres = Run_subst("$engine -fmt \"$comdir/$comname\" $options $texname");
        return $finalres;
    }
}

# 共有フォーマットファイルの確認・確保・破棄
{
    # 確認
    sub check_com_owned(){
        my $path=$_[0];
        open(my $fh, "<", "$comdir/$comname.info");
        my $holder=<$fh>;
        close($fh);
        if($path eq $holder){
            return 1;
        }else{
            return 0;
        }
    }
    # 確保
    sub hold_com(){
        my $path=$_[0];
        open(my $fh, ">", "$comdir/$comname.info");
        print $fh "$path";
        close($fh);
    }
    # 破棄(生成失敗時用)
    sub throw_com(){
        open(my $fh, ">", "$comdir/$comname.info");
        print $fh "";
        close($fh);
    }
}

# プリアンブル差分検知
{
    my $prea_ext = "prea";
    $clean_ext="$clean_ext $prea_ext";

    # プリアンブル抽出用のコマンド(未改修)
    # \endofdumpまたは\begin{document}まで読み出して保存
    my $gethead = "awk '!/%.*/{if (p) print}BEGIN{p=1}/\\\\endofdump/{p=0}/\\\\begin\\{document\\}/{p=0}'";
    my $comphead = "sed -e 's/ *\$//g' -e 's/%.*\$//g'";

    sub check_preamble_change{
        my ($auxpath, $basename, $texname) = @_;
        my $preapath="$auxpath$basename.$prea_ext";
        # プリアンブル部の一時ファイルをクリア
        system("echo \"\" > \"$preapath.tmp\"");

        my $chain_flag=1;
        # subfilesによるプリアンブル依存が終わるまで続ける
        do{
            system("$gethead \"$texname\"|$comphead >> \"$preapath.tmp\"");
            system("echo \"\" >> \"$preapath.tmp\"");

            # subfilesの利用を検出
            # 第1行が\documentclass[親ファイルパス]{subfiles}であればsubfiles使用とする
            my $mastername = `head -n 1 "$texname"`;
            if ($mastername =~ /^ *\\documentclass\[.*\]\{subfiles\} *$/){
                $mastername =~ s/^ *\\documentclass\[//g;
                $mastername =~ s/\]\{subfiles\} *$//g;
            }else{
                $mastername = "";
            }
            chomp($mastername);
            # $masternameはsubfilesを利用していれば拡張子なしの親ファイルパスが入っている
            if ($mastername ne ""){
                $texname = "$mastername.tex";
            }else{
                $chain_flag=0;
            }
        }while($chain_flag == 1);
        # 比較
        my $checkret = system("diff -Bb \"$preapath.tmp\" \"$preapath\"");
        return $checkret;
    }
    sub forget_preamble_change{
        my ($auxpath, $basename) = @_;
        system("rm \"$auxpath$basename.$prea_ext.tmp\"");
    }
    sub memorize_preamble_change{
        my ($auxpath, $basename) = @_;
        system("mv \"$auxpath$basename.$prea_ext.tmp\" \"$auxpath$basename.$prea_ext\"");
    }
}

に置換。mylatexformatでLaTeX高速化(latexmk・Overleaf対応)[むしゃくしゃしてやった,今は反省している日記]mylatexformat を用いてコンパイル時間を短縮しよう![TeX Alchemist Online]などを参考にした。以下解説。

# fmtlatexの呼び出し
$pdf_mode = 3;
$latex = 'internal fmtlatex uplatex %Z %Y %A %S %R -synctex=1 -file-line-error -halt-on-error %O';
$dvipdf = 'dvipdfmx %O -o %D %S';
$max_repeat = 5;

internal fmtlatexPerlのサブルーチンとしてのfmtlatexを呼び出すことができる。引数がやたら多いのは後で使うため。

# 作業パス
my $comdir=$ENV{HOME};
my $comname=".latexmk";
my $pwd=`pwd`;
chomp $pwd;

フォーマットファイルおよび関連ファイルを~/.latexmk.<拡張子>で生成すると設定。これはフォーマットファイルのサイズが数十MBぐらいあり、そのままだと各.texファイルにつき数十MBが消費されるため。.texファイルの編集作業の局所性を活かして、フォーマットファイルをキャッシュとしてだけ保持する形にして節約している。またクラウドドライブに巨大な書き込みをしないためでもある。 以降、このホームディレクトリ直下に生成されるフォーマットファイルを共通フォーマットファイルと呼ぶことにする。

# fmtlatex メインルーチン
{
    # 拡張子を登録
    $clean_ext="$clean_ext fmt";
    my $initial = 1;

    sub fmtlatex {
        # 引数読込
        my ($engine, $outpath, $auxpath, $basename, $texname, $jobname, @args) = @_;
        my $options = join(' ', @args);

        # 初回実行時
        if ($initial == 1){
            $initial = 0;
            # フォーマット生成フラグ
            my $flag = 0;
            print "fmtlatex: checking if the preamble changed...\n";
            if (&check_preamble_change($auxpath,$jobname,$texname) == 0){
                print "fmtlatex: the preamble is not changed.\n";
                print "fmtlatex: checking if the common fmt file is owned...\n";
                if (&check_com_owned("$pwd/$texname") == 0){
                    print "fmtlatex: the common fmt file is not owned.\n";
                    $flag = 1;
                }else{
                    print "fmtlatex: the common fmt file is owned.\n";
                }
            }else{
                print "fmtlatex: the preamble is changed.\n";
                $flag = 1;
            }
            if ($flag == 1){
                print "rewriting the common fmt file in ini mode...\n";
                # フォーマット生成
                my $iniret=Run_subst("$engine -ini $options -output-directory=\"$comdir\" -jobname=\"$comname\" \\\&$engine mylatexformat.ltx $texname");
                if($iniret == 0){
                    print "fmtlatex: the common fmt file rewrited. saving preamble...\n";
                    &memorize_preamble_change($auxpath,$jobname);
                    &hold_com("$pwd/$texname");
                }else{
                    print "fmtlatex: failed to rewrite the common fmt file.\n";
                    &forget_preamble_change($auxpath,$jobname);
                    &throw_com("$pwd/$texname");
                    return $iniret;
                }
            }else{
                print "keep the common fmt file.\n";
                &forget_preamble_change($auxpath,$jobname);
            }
        }
        print "fmtlatex: the common fmt file is ready, so running normal latex... \n";
        # 通常のタイプセット
        my $finalres = Run_subst("$engine -fmt \"$comdir/$comname\" $options $texname");
        return $finalres;
    }
}

メインルーチンは専らmylatexformatでLaTeX高速化(latexmk・Overleaf対応)[むしゃくしゃしてやった,今は反省している日記]のそれを拡張した形になっている。条件分岐は以下の通り。

  • 初回タイプセットである ↓
    • プリアンブルが変更されていない ↓
      • 共通フォーマットファイルが維持されている →通常のタイプセット
      • 共通フォーマットファイルが維持されていない →フォーマット生成+通常のタイプセット
    • プリアンブルが変更されている →フォーマット生成+通常のタイプセット
  • 初回タイプセットではない → 通常のタイプセット

これにより、真にフォーマットファイル生成が必要な時のみ生成することになり、ほとんどのタイプセットでプリアンブル部分のタイプセットを省略することになる。 全体のコードが中括弧{}で囲ってあるのは変数のスコープを切るため。グローバル空間に$initialが置かれてるのは怖すぎる。

# 共有フォーマットファイルの確認・確保・破棄
{
    # 確認
    sub check_com_owned(){
        my $path=$_[0];
        open(my $fh, "<", "$comdir/$comname.info");
        my $holder=<$fh>;
        close($fh);
        if($path eq $holder){
            return 1;
        }else{
            return 0;
        }
    }
    # 確保
    sub hold_com(){
        my $path=$_[0];
        open(my $fh, ">", "$comdir/$comname.info");
        print $fh "$path";
        close($fh);
    }
    # 破棄(生成失敗時用)
    sub throw_com(){
        open(my $fh, ">", "$comdir/$comname.info");
        print $fh "";
        close($fh);
    }
}

$comdir/$comname.info"、すなわち~/.latexmk.infoにはタイプセット対象の.texファイルのパスが書き込まれる。これを以って共通フォーマットファイルの生成元を識別する。多分Perlチョットデキル人にかかればもっといい感じ[8]にできるんだろうが、個人用PCではそこまで問題にならないだろう。

# プリアンブル差分検知
{
    my $prea_ext = "prea";
    $clean_ext="$clean_ext $prea_ext";

    # プリアンブル抽出用のコマンド(未改修)
    # \endofdumpまたは\begin{document}まで読み出して保存
    my $gethead = "awk '!/%.*/{if (p) print}BEGIN{p=1}/\\\\endofdump/{p=0}/\\\\begin\\{document\\}/{p=0}'";
    my $comphead = "sed -e 's/ *\$//g' -e 's/%.*\$//g'";

    sub check_preamble_change{
        my ($auxpath, $basename, $texname) = @_;
        my $preapath="$auxpath$basename.$prea_ext";
        # プリアンブル部の一時ファイルをクリア
        system("echo \"\" > \"$preapath.tmp\"");

        my $chain_flag=1;
        # subfilesによるプリアンブル依存が終わるまで続ける
        do{
            system("$gethead \"$texname\"|$comphead >> \"$preapath.tmp\"");
            system("echo \"\" >> \"$preapath.tmp\"");

            # subfilesの利用を検出
            # 第1行が\documentclass[親ファイルパス]{subfiles}であればsubfiles使用とする
            my $mastername = `head -n 1 "$texname"`;
            if ($mastername =~ /^ *\\documentclass\[.*\]\{subfiles\} *$/){
                $mastername =~ s/^ *\\documentclass\[//g;
                $mastername =~ s/\]\{subfiles\} *$//g;
            }else{
                $mastername = "";
            }
            chomp($mastername);
            # $masternameはsubfilesを利用していれば拡張子なしの親ファイルパスが入っている
            if ($mastername ne ""){
                $texname = "$mastername.tex";
            }else{
                $chain_flag=0;
            }
        }while($chain_flag == 1);
        # 比較
        my $checkret = system("diff -Bb \"$preapath.tmp\" \"$preapath\"");
        return $checkret;
    }
    sub forget_preamble_change{
        my ($auxpath, $basename) = @_;
        system("rm \"$auxpath$basename.$prea_ext.tmp\"");
    }
    sub memorize_preamble_change{
        my ($auxpath, $basename) = @_;
        system("mv \"$auxpath$basename.$prea_ext.tmp\" \"$auxpath$basename.$prea_ext\"");
    }
}

.texファイルからプリアンブルを抽出し、<texファイル名(拡張子なし)>.prea.tmpに書き出し。これを前回の結果である<texファイル名(拡張子なし)>.preaと比較して差分を検出する。ここで、この後導入するsubfilesによるプリアンブル依存の解消もやっている。

かなーり場当たり的に拡張してきたので、もっといい感じ[8:1]に確実にできるが、いかんせん余暇がない。余裕がある人はお好きにどうぞ。

時間測定

後でやるかも?

subfilesの導入

ファイル分割パッケージにも色々あるが、プリアンブルを省略・共有できるsubfilesを使うのが一般的な用法だと相性が良さそうに見える。

使い方

例えば、subex.tex

\documentclass[uplatex,dvipdfmx,a4paper]{jsarticle}
\usepackage{subfiles}
\newcommand{\fermi}{フェルミオンのファミリーマート、フェルミーオーン}
\newcommand{\bose}{ボソンのローソン、ボーソン}
\begin{document}
    \section{メインファイル}
    \fermi
    \subfile{subexsub}
\end{document}

subexsub.tex

\documentclass[./subex]{subfiles}

\begin{document}
    \section{サブファイル}
    \bose
\end{document}

にすると(.texの拡張子はいらないことに注意)、それぞれこんな感じの.pdfファイルが出力される。

subex.png

subexsub.png

subex.texで定義した\bosesubexsub.texでも使えていることがわかる。

分割した LaTeX ファイルを subfiles を使ってコンパイルするを参考にした。

終わりに

良い子のみんなは初めからTypet使って幸せになろうね。


  1. マシンパワーで殴らないの意。 ↩︎

  2. 独自テンプレートだったり、オレオレスタイルファイルだったり ↩︎

  3. OS固有の機能は使っていないので、移植は可能。 ↩︎

  4. CLIからの使用にも耐えるはずなので、枝葉末節。 ↩︎

  5. TeX Liveに含まれている。公式ページのようなものが見つからなかったので、ご存じの方情報提供お願いします。とりあえず作業過程が書かれているIssueを貼っておく。 ↩︎

  6. 余談だが、友人が全ての.texファイルを同一のフォルダに納めており、恐怖を禁じ得なかった。 ↩︎

  7. 構成が適切であること。Unicodeにデフォルト対応だったり、OS側のフォントを使えたりすることを指している。 ↩︎

  8. 複数フォーマット保持できたり、複数プロセスに対応したり。 ↩︎ ↩︎