FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

DSLでビルドツールを自作する (7日目 タイムスタンプでルール実行をスキップする)

この記事は「Qiita Advent Calendar 2019 DSLで自作ビルドツールを作ろう」の7日目の記事です。

7日目 タイムスタンプでルール実行をスキップする

いよいよビルドツールの本命ともいえる機能です。タイムスタンプを使用して、更新のないファイルに対sるビルドをスキップすることで、ビルドを高速化する機能を追加します。この機能について調べたくてビルドツールDSLを作り始めたといっても過言ではありません。

しかし、そもそもMakeのビルドツールはどのようにしてタイムスタンプを判定しているのでしょうか。いろいろ文献を調べてみたのですが、やはりターゲットファイルとソースファイルタイムスタンプ差を判別して、ビルドのスキップを決めているようです。例えば、下記のようなMakeルールがあった場合、

makefile test.exe : test1.c test2.c gcc -o $^ $@

test.exetest1.ctest2.cをベースに作られるため、test.oのタイムスタンプはtest1.ctest2.cよりも新しいはずです。もしそうでなければ、test.exeが作られた後にtest1.ctest2.cは更新されたと考えるべきです。その場合は、再度ルールを実行してtest.exeを作り直すべきです。このように考えると、

  • ソースファイルのタイムスタンプとターゲットのタイムスタンプを比較して、ソースのうちどれか1つでもターゲットよりも新しいタイムスタンプがあればターゲットを実行する。

とルールを定めれば実現できそうです(最初私はすべてのファイルとターゲットのタイムスタンプをどこかに記録してるのかと思ったのですが、もっと単純に実現できるのですね...)。

逆にいうと、シンボルに対するタイムスタンプという概念は存在しないため、ターゲットがシンボルの場合はタイムスタンプを確認せずに何度も再実行されるのですね。例えば、以下の2つのルールは根本的に違うようで、

testA.exe : test1.c test2.c
   gcc -o testA.exe test1.c test2.c
test_compile: test1.c test2.c  # ターゲットがファイル名ではなくシンボル!
   gcc -o testA.exe test1.c test2.c

1番目はtestA.exeが一度生成されれば、test1.ctest2.cが更新されない限り一度だけビルドが実行されますが、2番目はtest_compileというシンボルにはタイムスタンプが存在しないため、再実行すると何度でもコンパイルが実行されてしまいます。大変!

そこで、ターゲット名がファイル名として存在している場合はタイムスタンプをチェックし、コンパイル必要がないならばスキップ、ターゲット名がラベルの場合は問答無用でビルド実行、というフローを作ってみます。

Rubyでファイルのタイムスタンプを取得する仕組み

これも少しだけ調べただけですが、File::Statというオブジェクト使用することでファイル名からファイルオブジェクトを取得し、タイムスタンプを調査することができるようです。

test_stat = File::Stat.new("test.c")
test_stat.mtime 最終更新時刻
test_stat.ctime 最終状態変更時刻
                (状態の変更とは chmod などによるもので、Unix では i-node の変更を意味します)
test_stat.atime 最終アクセス時刻

この機能を使ってタイムスタンプを取得しながらビルドスキップの判定を行います。ざっくりとしたフローは以下です。

// ターゲットは依存するファイルのうちどれか1つよりもタイムスタンプが古い?
target_older_1of_depends = False
ターゲットが文字列(ファイル名)ならば {
  すべての依存するターゲットについて {
     依存するターゲットが文字列(ファイル名)ならば {
       依存数ファイルのタイムスタンプを取得。ターゲットファイルのタイムスタンプを取得
       ターゲットの方が古い? {
         target_older_1of_depends = True
       }
     } else {
       // 依存ターゲットの中にシンボルがあればタイムスタンプが分からないのでスキップしない
       target_older_1of_depends = True
     }
  }
} else {
  // ターゲット名がシンボル名の場合はタイムスタンプが分からないのでスキップしない
  target_older_1of_depends = True
}
target_older_1of_depends が False? {
  ビルド実行
}

これをそのまま書き下します。do_targetの中に記述します。

private def do_target (name)
  if @target_list.key?(name) then
    target = @target_list[name]

    # Execute dependent commands at first
    check_target = false
    if name.kind_of?(String) and File.exist?(name) then
      target_stat = File::Stat.new(name)
      puts "[DEBUG] : target mtime: #{target_stat.mtime}"

      check_target = true
    end

    target_older_1of_depends = false
    target.depend_targets.each{|dep|
      if not @target_list.key?(dep) and not Symbol.all_symbols.include?(dep) then
        puts "[DEBUG] : Depend Tareget \"#{dep}\" is skip because it's file."
        if check_target == true and dep.kind_of?(String) then
          dep_stat = File::Stat.new(dep)
          puts "[DEBUG] : depends mtime: #{dep_stat.mtime}"

          if target_stat.mtime < dep_stat.mtime then
            target_older_1of_depends = true
          end
        else
          # If dependence list includes symbol, need to execute
          target_older_1of_depends = true
        end
        next
      else
        # one of depends are symbol => forcely re-execute target
        target_older_1of_depends = true
      end
      puts "[DEBUG] : Depends Target \"#{dep}\" execute."
      do_target(dep)
    }

    if target_older_1of_depends then
      # Execute commands!
      target.commands.each {|command|
        puts "#{command}"
        result = `#{command}`
        puts result
      }
    end
  else
    puts "Error: target \"#{name}\" not found."
  end
end

ちょっと長いですが、結局やっているのはここで、

          dep_stat = File::Stat.new(dep)
          puts "[DEBUG] : depends mtime: #{dep_stat.mtime}"

          if target_stat.mtime < dep_stat.mtime then
            target_older_1of_depends = true
          end

タイムスタンプに応じて最後おターゲット実行を行うかどうか決めているだけです。

タイムスタンプチェック機能をテストする

では、テストとして以下を用意します。

  • tests/check_timestamp.rb
#!/usr/bin/ruby

load "rumy-exec.rb"

make_target "test" do
  global
  depends ["test.c"]
  executes ["gcc test.c -o test"]
end

exec_target "test"

めっちゃ単純ですがとりあえずこれで行きます。まずは普通に実行します。

$ ruby ../tests/check_timestamp.rb
[DEBUG] : Target Created  = test, Depends = , Commands = ["gcc test.c -o test"]
[DEBUG] : Depend Tareget "test.c" is skip because it's file.

gcc test.c -o test

最後の1行です。コンパイルが実行されました。では、再度同じコマンドを流します。

ruby ../tests/check_timestamp.rb
[DEBUG] : Target Created  = test, Depends = , Commands = ["gcc test.c -o test"]
[DEBUG] : target mtime: 2019-10-25 23:19:30 +0800
[DEBUG] : Depend Tareget "test.c" is skip because it's file.
[DEBUG] : depends mtime: 2019-10-25 22:24:03 +0800

デバッグメッセージにも表示されていますが、ターゲットと依存ターゲットのタイムスタンプは、ターゲットの方が古いためビルドは実行されていません。上手く行きました。さらに、再びtest.cを触ってソースファイルのタイムスタンプを更新してみます。

$ touch test.c # test.cのタイムスタンプを更新する
$ ruby ../tests/check_timestamp.rb
[DEBUG] : Target Created  = test, Depends = , Commands = ["gcc test.c -o test"]
[DEBUG] : target mtime: 2019-10-25 23:19:30 +0800
[DEBUG] : Depend Tareget "test.c" is skip because it's file.
[DEBUG] : depends mtime: 2019-10-25 23:21:08 +0800

gcc test.c -o test

タイムスタンプを確認して、ビルドが再実行されています!基本的なタイムスタンプによるビルド省略システムは正しく動いているようです。ビルドツールっぽくなってきました!