RDoc 内のコードをオートフォーマットする gem を作ってみた

TL;DR

  • Ruby 公式コーディングスタイルほしい
  • 公式コーディングスタイル作成はトライしたが断念
  • RDoc 内の Ruby コードをオートフォーマットする rdoc_rubocop gem を作った

RDoc 内のコードをオートフォーマットしよう

きっかけは mattn さんの以下のツイート。

自分も前々から公式コーディングスタイルが欲しい (アプリや gem の新規作成の度にコーディングスタイルどうしようとほんのちょっとでも悩むのが面倒) と思っていたので、激しく同意。 ないのなら作ってみよう。

Ruby 公式コーディングスタイルを作ってみる

Ruby には RuboCop という静的コード解析&オートフォーマッタがある。 これにはコーディングスタイル違反を検出し、一旦保留にするための設定ファイルを生成する機能がある。 生成された設定ファイル (.rubocop_todo.yml) を手直しし RuboCop を適用することでオートフォーマットとして使えるので、試してみよう。

Ruby の公式コードは Ruby コアリファレンス のサンプルコードを利用することにする。

Ruby コアリファレンスに RuboCop

Ruby コアリファレンスは CRuby のコード中のコメントから RDoc コマンドで生成されている(はず)。 ここからサンプルコードを抽出しファイルに保存、このファイルに対して RuboCop すれば良さそうだ。

RDoc コマンドで生成されるのは HTML (デフォルトの場合)。 サンプルコードは ruby クラスが付与された pre タグ内にあるようなので、この部分を抜き出すスクリプトを作成する。

#!/usr/bin/env ruby

require 'pathname'
require 'fileutils'
require 'nokogiri'

class HtmlFile
  def initialize(path)
    @path = path
  end

  def load_and_parse
    @doc = File.open(@path) { |f| Nokogiri::HTML(f) }
  end

  def rubies
    @doc.xpath("//pre[@class='ruby']").map(&:inner_text)
  end
end

class Extractor
  SRC_DIR = Pathname("doc")
  DST_DIR = Pathname("sample")

  def initialize
  end

  def extract
    SRC_DIR.glob("**/*.html").each do |path|
      puts path
      html_file = HtmlFile.new(path)
      html_file.load_and_parse

      dst = DST_DIR + (path.sub("doc/", "").sub(/html$/, "rb"))
      FileUtils.mkdir_p(dst.dirname)
      dst.open("w") do |f|
        f.puts html_file.rubies.join("#---\n")
      end
    end
  end
end

Extractor.new.extract
#!/bin/sh

[ -d ruby ] || git clone git://github.com/ruby/ruby.git
(cd ruby && git checkout v2_5_3)

bundle exec rdoc --root=ruby

[ -d sample ] || mkdir sample
bundle exec ext_sample.rb
echo 2.5.3 > .ruby-version
bundle exec rubocop --auto-gen-config sample/

これを実行した結果、45KB、1,822 行の .rubocop_todo.yml が生成された。 事前になんとなく想像していたよりも多い。 表記ゆれも結構あり、Layout/SpaceBeforeBlockBracesStyle/NumericLiterals が多い様子。

思っていたよりも統一感がなく、ここから生成された設定ファイルを公式コーディングスタイルとするのは厳しそうだ。 ということで公式コーディングスタイルは一旦諦めることにした。

それよりも表記ゆれもあることだしサンプルコードの方を修正したくなってきた。 抽出したサンプルコードに対して RuboCop でオートフォーマットし、結果を元のソースコードに反映すればいいのではないか。

RDoc 内のサンプルコードをオートフォーマット

RDoc 自体はコンピュータ言語に依存しないが、通常は Ruby のコードか Ruby の C 拡張に用いられることが多いだろう。 また Ruby の C 言語による実装 CRuby にも多数使用されている。 ここでは C 言語および Ruby のメントを加工することを考える。

C 言語のコメントを加工する

CRuby のコードは以下のようになっている。

/* array.c から Array#at を抜粋 */

/*
 *  call-seq:
 *     ary.at(index)   ->   obj  or nil
 *
 *  Returns the element at +index+. A negative index counts from the end of
 *  +self+. Returns +nil+ if the index is out of range. See also
 *  Array#[].
 *
 *     a = [ "a", "b", "c", "d", "e" ]
 *     a.at(0)     #=> "a"
 *     a.at(-1)    #=> "e"
 */

VALUE
rb_ary_at(VALUE ary, VALUE pos)
{
    return rb_ary_entry(ary, NUM2LONG(pos));
}

ここからコメント部の /* ... */ を抜き出し、Ruby のサンプルコードをオートフォーマットしてから元の場所を置換できればいい。

コメント部を抜き出して置換する場合、String#subString#gsub が使える。単純な文字列置換の場合は "hello".sub(/e/, "*") の形式で利用するが、もう少し複雑な処理の場合はブロックを渡し、その中でマッチした部分に複雑な加工を施すこともできる。

code = File.open("array.c").read
code.gsub(/\/\*.*?\*\//) { |s| s.auto_correct_with_rubocop }

しかし今回はこの方法は使わない。なぜなら、RuboCop の処理中のプログレス表示が見づらいものになるからだ。RuboCop で複数のファイルを処理する場合を考えてみてほしい。通常はコマンドに複数のファイルをまとめて指定するはずだ。ファイルを一つだけ指定し複数回実行することは稀だろう。

# 複数のファイルをまとめて指定する場合
$ rubocop file1.c file2.c file3.c
Inspecting 3 files
...

3 files inspected, no

# ファイルを一つだけ指定して複数回実行
$ for f in file1.c file2.c file3.c; do rubocop $f; done
Inspecting 1 file
.

1 file inspected, no offenses detected
Inspecting 1 file
.

1 file inspected, no offenses detected
Inspecting 1 file
.

1 file inspected, no offenses detected

ではコメントを複数個まとめて抜き出し、加工をしてから元のコメントを置換することは可能か。MatchData を利用すれば可能だ。 MatchData は文字列と正規表現をマッチさせた結果を保持するクラスだが、これにはマッチした文字列の位置情報が含まれている。

m = /\d+/.match("THX1138.")
m[0] #=> "1138"
m.begin(0) #=> 3
m.end(0) #=> 7

m = /[A-Z](\d{2})/.match("THX1138.")
m[0] #=> "X11"
m[1] #=> "11"
m.begin(1) #=> 3
m.end(1) #=> 5

これと String#[]= を合わせて使うことでコメント置換ができる。

code = File.open("array.c").read

m = /\/\*.*?\*\//.match(code)
code[m.begin(0) .. m.end(0)] = m[0].auto_correct_with_rubocop

上記のコードには問題がある。コード中の一番最初のコメントしか抜き出せないのだ。Regexp#match は一度に複数のコメントとマッチしない。しかし幸いなことに、第二引数を指定すれば検索開始位置を変更できる。

m = /\d/.match("THX1138.")
m[0] #=> "1"

m = /\d/.match("THX1138.", 5)
m[0] #=> "3"

あとはこれをコメントがマッチしなくなるまでループすればいい。

code = File.open("array.c").read

pos = 0
comments = []
while m = /\/\*.*?\*\//.match(code, pos)
  comments << m
  pos = m.end(0)
end

comments #=> [#<MatchData "/* comment1 */">, #<MatchData "/* comment2 */">, ...]

これでコメントが複数であろうと抜き出せるようになった。

Ruby のコメントを加工する

Rails のコードを以下に抜粋する。

class Numeric
  # Returns the number of milliseconds equivalent to the seconds provided.
  # Used with the standard time durations.
  #
  #   2.in_milliseconds # => 2000
  #   1.hour.in_milliseconds # => 3600000
  def in_milliseconds
    self * 1000
  end
end

activesupport/lib/active_support/core_ext/numeric/time.rb から #in_milliseconds のみ抜粋

Ruby のコメントは # から行末までなので、これを抜き出せばいい(=begin, =end による埋め込みドキュメントは今回は保留)。 C 言語と異なり Ruby のコメントは 1 行なので、抜き出したあとで結合をする必要がある。

# extract_comments_ruby.rb

Line = Struct.new(:text, :lineno)

class Comment
  def initialize(lines)
    @lines = lines
  end

  def text
    @lines.map(&:text).join
  end

  def lineno
    @lines.map(&:lineno).minmax
  end
end

code = File.open("time.rb").readlines

comment_lines = code.
  map.with_index(1) { |line, lineno| Line.new(line, lineno) }.
  select { |line| /^\s*#/ =~ line.text }

comments = comment_lines.
  slice_when { |i, j| j.lineno - i.lineno > 1 }.
  map { |tokens| Comment.new(tokens) }

puts "length: #{comments.length}"
comments.each do |comment|
  puts ""
  puts "lineno: #{comment.lineno}"
  puts "text:\n" + comment.text
end

Enumerable#slice_when はブロックの評価結果が true のとき、そこを区切りとして Array をグループ化するメソッドだ。

[1, 2, 3, 5, 6, 9, 10].slice_when { |i, j| j - i > 1 }.to_a #=> [[1, 2, 3], [5, 6], [9, 10]]

今回は行番号が連続しているコメントを、一まとまりとして扱うために使用した。 以下のような状況を想定すると行番号が連続していても必ずしも一まとまりのコメントとはならないので、あくまで例として認識していただきたい。

# comment
def foo # another comment
  "foo"
end

実行してみると

$ ruby extract_comments_ruby.rb
length: 1

lineno: [2, 6]
text:
  # Returns the number of milliseconds equivalent to the seconds provided.
  # Used with the standard time durations.
  #
  #   2.in_milliseconds # => 2000
  #   1.hour.in_milliseconds # => 3600000

問題ないようだ。

ところでこの例ではコメントを正規表現 /\s*#/ にマッチさせて判定している。 が Ruby の標準添付ライブラリには Ripper という Ruby パーサがある。 これを使うと Ruby のコードを簡単かつ確実にパースすることができる。

require "ripper"
Ripper.lex(File.open("time.rb").read)
# 実行結果
[[[1, 0], :on_kw, "class", EXPR_CLASS],
 [[1, 5], :on_sp, " ", EXPR_CLASS],
 [[1, 6], :on_const, "Numeric", EXPR_ARG],
 [[1, 13], :on_nl, "\n", EXPR_BEG],
 [[2, 0], :on_sp, "  ", EXPR_BEG],
 [[2, 2],
  :on_comment,
  "# Returns the number of milliseconds equivalent to the seconds provided.\n",
  EXPR_BEG],
 [[3, 0], :on_sp, "  ", EXPR_BEG],
 [[3, 2], :on_comment, "# Used with the standard time durations.\n", EXPR_BEG],
 [[4, 0], :on_sp, "  ", EXPR_BEG],
 [[4, 2], :on_comment, "#\n", EXPR_BEG],
 [[5, 0], :on_sp, "  ", EXPR_BEG],
 [[5, 2], :on_comment, "#   2.in_milliseconds # => 2000\n", EXPR_BEG],
 [[6, 0], :on_sp, "  ", EXPR_BEG],
 [[6, 2], :on_comment, "#   1.hour.in_milliseconds # => 3600000\n", EXPR_BEG],
 [[7, 0], :on_sp, "  ", EXPR_BEG],
 [[7, 2], :on_kw, "def", EXPR_FNAME],
 [[7, 5], :on_sp, " ", EXPR_FNAME],
 [[7, 6], :on_ident, "in_milliseconds", EXPR_ENDFN],
 [[7, 21], :on_nl, "\n", EXPR_BEG],
 [[8, 0], :on_sp, "    ", EXPR_BEG],
 [[8, 4], :on_kw, "self", EXPR_END],
 [[8, 8], :on_sp, " ", EXPR_END],
 [[8, 9], :on_op, "*", EXPR_BEG],
 [[8, 10], :on_sp, " ", EXPR_BEG],
 [[8, 11], :on_int, "1000", EXPR_END],
 [[8, 15], :on_nl, "\n", EXPR_BEG],
 [[9, 0], :on_sp, "  ", EXPR_BEG],
 [[9, 2], :on_kw, "end", EXPR_END],
 [[9, 5], :on_nl, "\n", EXPR_BEG],
 [[10, 0], :on_kw, "end", EXPR_END],
 [[10, 3], :on_nl, "\n", EXPR_BEG]]

パースするとコードはトークンに分解され、トークンは [[lineno, column], type, token, state] という Array として返される。 lineno は行番号、column はカラム位置、typeトークンの型だ。 トークンがコメントのとき typeon_comment になるので、これを集めればいい。

require "ripper"

Token = Struct.new(:lineno, :column, :type, :token, :state)

class Comment
  def initialize(tokens)
    @tokens = tokens
  end

  def text
    @tokens.map(&:token).join
  end

  def lineno
    @tokens.map(&:lineno).minmax
  end
end

code = File.open("time.rb").read

comment_tokens = Ripper.lex(code).
  map(&:flatten).
  map { |token| Token.new(*token) }.
  select{ |token| token.type == :on_comment }

comments = comment_tokens.
  slice_when { |i, j| j.lineno - i.lineno > 1 }.
  map { |tokens| Comment.new(tokens) }

puts "length: #{comments.length}"
comments.each do |comment|
  puts ""
  puts "lineno: #{comment.lineno}"
  puts "text:\n" + comment.text
end

実行すると

$ ruby extract_comments_ruby.rb
length: 1

lineno: [2, 6]
text:
# Returns the number of milliseconds equivalent to the seconds provided.
# Used with the standard time durations.
#
#   2.in_milliseconds # => 2000
#   1.hour.in_milliseconds # => 3600000

こちらも問題ない。

RuboCop を使う

stdin オプションを使ってみる

RuboCop に文字列を直接指定して使用することはできるか。rubocop コマンドには --stdin オプションが用意されており、ファイルの代わりに標準入力・標準出力を利用できる。

$ echo "[1 , 2]" | rubocop --stdin sample.rb
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^

1 file inspected, 1 offense detected

オートコレクトもできる。

$ echo "[1 , 2]" | rubocop --stdin sample.rb --auto-correct
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^

1 file inspected, 1 offense detected, 1 offense corrected
====================
[1, 2]

コマンド実行結果は省略するが、--auto-gen-config.rubocop_todo.yml を生成することもできた。おそらく他のオプションも利用可能だ。 しかしこの方法では一塊のコードしか指定できず、先のプログレス表示問題に再度突き当たってしまう。

rubocop コマンドには複数のファイルを指定できることは先に記載したとおり。 ではどのように実装されているかを調べ、そこをうまく利用できないかを検討しよう。

ファイルを読み込む処理を変更してみる

まず RuboCop が指定されたファイルまたは標準入力を読み出す処理を探してみる。すると以下のコードが見つかる。

module RuboCop
  class Runner
    # :

    def get_processed_source(file)
      ruby_version = @config_store.for(file).target_ruby_version

      if @options[:stdin]
        ProcessedSource.new(@options[:stdin], ruby_version, file)
      else
        ProcessedSource.from_file(file, ruby_version)
      end
    end
  end
end

module RuboCop
  class ProcessedSource
    def self.from_file(path, ruby_version)
      file = File.read(path, mode: 'rb')
      new(file, ruby_version, path)
    end

    def initialize(source, ruby_version, path = nil)
      @raw_source = source
      @path = path
      @ruby_version = ruby_version
    end

  # :
end

※いずれのクラスも要所のみ抜粋。以後、引用するコードも同様

ProcessedSource は対象となるソースコードを保持するためのクラスのようだ。

RuboCop::Runner#get_processed_sourceソースコードを渡した場合、それを元に RuboCop::ProcessedSourceインスタンスを返すようにすればどうだろう。 既存のコードの動作を変更するには prepend が便利だ。

# test.rb

require "rubocop"

SourceCode = Struct.new(:filepath, :source)

module RunnerModifier
  private

  def get_processed_source(file)
    if file.is_a?(SourceCode)
      ruby_version = @config_store.for(file.filepath).target_ruby_version
      RuboCop::ProcessedSource.new(file.source, ruby_version, file.filepath)
    else
      super
    end
  end
end

RuboCop::Runner.prepend RunnerModifier

cli = RuboCop::CLI.new
source_code = SourceCode.new("sample.rb", "[1 , 2]")
cli.run([source_code])
puts source_code.source

ruby_version を得るためにほぼ同じコードを 2 箇所に書くことになり少々嫌な気分になるが、とにかく実行してみよう。

rubocop コマンドは内部で RuboCop::CLI クラスのインスタンスを生成し、これにコマンドの引数を配列として渡している。 コードの最後の部分はこれを簡略化したものだ。

$ bundle exec ruby test.rb
no implicit conversion of SourceCode into String
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:36:in `directory?'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:36:in `block in find'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:35:in `each'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:35:in `find'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:67:in `find_target_files'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:43:in `run'
# snip

RuboCop::TargetFinder#find は引数がファイルなら AllCops/Exclude に含まれるかをチェックし、ディレクトリなら再帰的にファイルを検索するメソッドだ。 上記のコードでは SourceCode という構造体クラスを定義したが、これが引数に String を期待する既存のコードにとって良くなかったようだ。 代わりに String クラスを拡張したものを使用する。

# test.rb

require "rubocop"

class FilePath < String
  attr_accessor :source

  def initialize(str, source)
    super(str)
    @source = source
  end
end

module RunnerModifier
  private

  def get_processed_source(file)
    if file.is_a?(FilePath)
      ruby_version = @config_store.for(file).target_ruby_version
      RuboCop::ProcessedSource.new(file.source, ruby_version, file)
    else
      super
    end
  end
end

RuboCop::Runner.prepend RunnerModifier

cli = RuboCop::CLI.new
source_code = FilePath.new("sample.rb", "[1 , 2]")
cli.run([source_code])
puts source_code.source
$ bundle exec ruby test.rb
Inspecting 1 file


0 files inspected, no offenses detected
Error: No such file or directory: /path/to/dir/sample.rb

"sample.rb" がないというエラーが出てしまった。 よく見ると source_code を指定したにもかかわらず 0 files inspected となってしまっている。

調べてみると、先の RuboCop::Runner#get_processed_sourcesample.rb を開こうとして見つからないという状態のようだ。 おかしい。そこは FilePath クラスのインスタンスを受け取るとファイルを読み込む代わりにソースコードを利用するよう改修したはずだ。

原因を調べるために、デバッガを使って詳しく調べてみよう。 ruby コマンドに -rdebug を指定することで利用できる。

$ bundle exec ruby -rdebug test2.rb
/home/user/.rbenv/versions/2.6.0/lib/ruby/2.6.0/x86_64-linux/continuation.so: warning: callcc is obsolete; use Fiber instead
Debug.rb
Emacs support available.

/home/user/.rbenv/versions/2.6.0/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:35:    RUBYGEMS_ACTIVATION_MONITOR.enter

# ブレイクポイントをセットする対象の RuboCop::Runner を事前にロードする
(rdb:1) require "rubocop"
true

# RuboCop::Runner:get_processed_source にブレイクポイントをセット
(rdb:1) break RuboCop::Runner:get_processed_source
Set breakpoint 1 at RuboCop::Runner:get_processed_source

# Ruby スクリプトを実行
(rdb:1) cont
Inspecting 1 file
Breakpoint 1, get_processed_source at /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:get_processed_source
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:350:    def get_processed_source(file)

(rdb:1) file.class
String

引数 file のクラスが FilePath ではなくて String になってしまっていた。 これでは存在しないファイル "sample.rb" を開こうとして失敗するのは当然だ。 file は一体どこで String になってしまったのか。 RuboCop::CLI は内部で RuboCop::Runnerインスタンスを生成し、run メソッドを呼び出すようになっている。 ひとまずここから見てみよう。

module RuboCop
  class Runner
    def run(paths)
      target_files = find_target_files(paths)
      if @options[:list_target_files]
        list_files(target_files)
      else
        warm_cache(target_files) if @options[:parallel]
        inspect_files(target_files)
      end
    end

    def find_target_files(paths)
      target_finder = TargetFinder.new(@config_store, @options)
      target_files = target_finder.find(paths)
      target_files.each(&:freeze).freeze
    end
  end
end

先ほどと同様にデバッガで詳しく見ていく。

#  `RuboCop::Runner#run` にブレイクポイントをセット
(rdb:1) break RuboCop::Runner:run
Set breakpoint 2 at RuboCop::Runner:run

# スクリプトを実行
(rdb:1) c
Breakpoint 1, run at /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:run
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:42:    def run(paths)

# 引数 paths 内のクラスを確認する
(rdb:1) paths
["sample.rb"]

(rdb:1) paths[0].class
FilePath # この時点では FilePath


(rdb:1) next
/home/masaoo/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:43:      target_files = find_target_files(paths)

(rdb:1) next
/home/masaoo/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:44:      if @options[:list_target_files]

# ローカル変数 target_files 内のクラスを確認する
(rdb:1) target_files
["/path/to/dir/sample.rb"]

(rdb:1) target_files[0].class
String # ここで FilePath でなくなっている

先に出てきた RuboCop::TargetFinder#find が犯人だった。 コードを見てみると、ファイル名を絶対パスに変換する処理 (File.expand_path(f)) があるのがわかる。

module RuboCop
  class TargetFinder
    def find(args)
      return target_files_in_dir if args.empty?

      files = []

      args.uniq.each do |arg|
        files += if File.directory?(arg)
                   target_files_in_dir(arg.chomp(File::SEPARATOR))
                 else
                   process_explicit_path(arg)
                 end
      end

      files.map { |f| File.expand_path(f) }.uniq
    end
  end
end

ではここも prepend で動作を変えよう。

# RunnerModifier 以外の部分は同じ

module RunnerModifier
  private

  def find_target_files(paths)
    file_paths, strs = paths.partition { |path| path.is_a?(FilePath) }

    if file_paths.empty?
      super(strs)
    elsif strs.empty?
      file_paths
    else
      file_paths + super(strs)
    end
  end

  def get_processed_source(file)
    if file.is_a?(FilePath)
      ruby_version = @config_store.for(file).target_ruby_version
      RuboCop::ProcessedSource.new(file.source, ruby_version, file)
    else
      super
    end
  end
end

# snip
bundle exec ruby test.rb 
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^
sample.rb:1:8: C: Layout/TrailingBlankLines: Final newline missing.
[1 , 2]
       

1 file inspected, 2 offenses detected

うまくいった! オートコレクトも試してみよう。cli.run の引数に --auto-correct を追加する。

# 上のコードは同じ

cli = RuboCop::CLI.new
source_code = FilePath.new("sample.rb", "[1 , 2]")
cli.run(["--auto-correct", source_code])
$ bundle exec ruby test3.rb
Inspecting 1 file


0 files inspected, no offenses detected
No such file or directory @ rb_sysopen - sample.rb
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:47:in `read'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:47:in `deserialize_offenses'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:13:in `from_json'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/result_cache.rb:95:in `load'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:134:in `file_offense_cache'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:124:in `file_offenses'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:112:in `process_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:89:in `block in each_inspected_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `reduce'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each_inspected_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:76:in `inspect_files'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:48:in `run'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:174:in `execute_runner'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:75:in `execute_runners'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:47:in `run'
test.rb:43:in `<main>'

またエラーだ。今度の原因はなんだろうか。 RuboCop は解析の結果をキャッシュファイルに保存しておき、それを再利用する仕組みがある。 これをロードする際に解析対象のファイルの内容も同時に読み込むのだが、ソースコードは標準入力なので失敗するというわけだ。

キャッシュを利用しないようにするには rubocop コマンドに -C --cache を指定すればいいのだが、ここの実装はどうなっているかというと

module RuboCop
  class Runner
    def cached_run?
      @cached_run ||=
        (@options[:cache] == 'true' ||
         @options[:cache] != 'false' &&
         @config_store.for(Dir.pwd).for_all_cops['UseCache']) &&
        !@options[:auto_gen_config] &&
        !@options[:stdin]
    end
  end
end

--cache オプション、AllCops/UseCache など参照していることがわかる。 注目は最終行だ。なるほど、--stdin オプションが指定されたときもキャッシュが無効になっていたのか。

ということは #cached_run? が常に false を返すようにすればいい。 RunnerModifier モジュールに以下のメソッドを追加してみよう。

  def cached_run?
    false
  end

実行すると

$ bundle exec ruby test.rb 
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^
sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing.
[1 , 2]
       

0 files inspected, 2 offenses detected, 2 offenses corrected
Infinite loop detected in sample.rb.
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:270:in `check_for_infinite_loop'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:253:in `block in iterate_until_no_changes'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:252:in `loop'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:252:in `iterate_until_no_changes'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:223:in `do_inspection_loop'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:126:in `block in file_offenses'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:144:in `file_offense_cache'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:124:in `file_offenses'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:112:in `process_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:89:in `block in each_inspected_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `reduce'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each_inspected_file'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:76:in `inspect_files'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:48:in `run'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:174:in `execute_runner'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:75:in `execute_runners'
/home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:47:in `run'
test.rb:47:in `<main>'

エラーの場所が変わった。 メソッド名は #check_for_infinite_loop だ。コードはどうなっているか。

    def check_for_infinite_loop(processed_source, offenses)
      checksum = processed_source.checksum

      if @processed_sources.include?(checksum)
        raise InfiniteCorrectionLoop.new(processed_source.path, offenses)
      end

      @processed_sources << checksum
    end

ProcessedSourceチェックサム@processed_sources に登録済みなら InfiniteCorrectionLoop 例外を上げるようにしている。 違反があるもののオートコレクト後のコードに変更がなければ無限ループに陥る。 これを検知し防止する機能には納得がいく。

ここで例外が上がるということは stdin で渡されたコードにオートコレクト結果がうまく反映されていないのかもしれない。 呼び出し元のコードを見てみよう。

    def do_inspection_loop(file, processed_source)
      offenses = []

      iterate_until_no_changes(processed_source, offenses) do
        offenses.select!(&:corrected?)
        new_offenses, updated_source_file = inspect_file(processed_source)
        offenses.concat(new_offenses).uniq!

        break unless updated_source_file

        processed_source = get_processed_source(file)
      end

      [processed_source, offenses]
    end

    def iterate_until_no_changes(source, offenses)
      @processed_sources = []
      iterations = 0

      loop do
        check_for_infinite_loop(source, offenses)

        if (iterations += 1) > MAX_ITERATIONS
          raise InfiniteCorrectionLoop.new(source.path, offenses)
        end

        source = yield
        break unless source
      end
    end

元のコードではコメントに興味深いメモがいくつか書いてあるが、ここでは割愛している。 RuboCop をより深く理解したい方はぜひ元のコードを参照していただきたい。

#iterate_until_no_changes では先の #check_for_infinite_loop で無限ループをチェックしているほか、ループ回数にも上限を設けている。 #do_inspection_loop にはループ内の実処理が書かれている。 …おや、ここにも #get_processed_source がある。 ソースコードを再取得しているということは、ここにオートコレクト結果が反映されていないのかもしれない。 結果として無限ループに陥っている恐れがある。

#get_processed_source があるブロック内でオートコレクトをしていそうなのはどこだろうか。 #inspect_file がそれっぽいようだ。

    def inspect_file(processed_source)
      config = @config_store.for(processed_source.path)

      team = Cop::Team.new(mobilized_cop_classes(config), config, @options)
      offenses = team.inspect_file(processed_source)

      [offenses, team.updated_source_file?]
    end

まだオートコレクトの実装は見えない。 RuboCop::Cop::Team#inspect_file を追ってみる。 とその名もずばり #autocorrect メソッドがあった。

module RuboCop
  module Cop
    class Team
      def autocorrect(buffer, cops)
        new_source = autocorrect_all_cops(buffer, cops)

        if @options[:stdin]
          @options[:stdin] = new_source
        else
          filename = buffer.name
          File.open(filename, 'w') { |f| f.write(new_source) }
        end
      end
    end
  end
end

オートコレクトした結果をファイルに書き出している! RuboCop::Runner#do_inspection_loop 内で #get_processed_source によってコードを再取得しているのはこの仕様によるものだろう。

そしてオートコレクトされた new_source はそれ以外には使われない。これでは無限ループが検出されるのも当然だ。 オートコレクト結果を利用するにはどうしたらいいだろう。 もう一度コードを見てみると、new_source を使用している部分はまだある。 --stdin オプションが指定されたときは @options[:stdin] に記録されるようだ。 これは利用できるのではないだろうか。

stdin 再び

RuboCop::Cop::Team#autocorrect で stdin 側の処理を利用するのなら、メソッドを呼び出す直前で @options[:stdin]FilePath#source セットしてみよう。 念のため、そのあとで @options[:stdin] を元の状態に戻しておこう。 FilePath は 引数 cops から取得できる。

module TeamModifier
  def autocorrect(buffer, cops)
    code = cops[0].processed_source.path

    if code.is_a?(FilePath)
      stdin_backup = @options[:stdin]
      @options[:stdin] = code.source
      result = super
      code.source = @options[:stdin]
      @options[:stdin] = stdin_backup

      result
    else
      super
    end
  end
end

RuboCop::Cop::Team.prepend TeamModifier
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^
sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing.
[1 , 2]
       

1 file inspected, 2 offenses detected, 2 offenses corrected
[1, 2]

オートコレクトされた結果が得られた!stdin バンザイ! とここで改めて RuboCop::Runner#get_processed_source を見てみると

module RuboCop
  class Runner
    # :

    def get_processed_source(file)
      ruby_version = @config_store.for(file).target_ruby_version

      if @options[:stdin]
        ProcessedSource.new(@options[:stdin], ruby_version, file)
      else
        ProcessedSource.from_file(file, ruby_version)
      end
    end
  end
end

@options[:stdin] で分岐しているという点が共通している。 同じ手法が使えそうだ。 抽象化してモジュールとして抽出し、これをつかうようにしてみよう。

require "rubocop"

class FilePath < String
  attr_accessor :source

  def initialize(str, source)
    super(str)
    @source = source
  end
end

module HijackSTDIN
  def hijack_stdin_opt(filepath)
    if filepath.is_a?(FilePath)
      stdin_backup = @options[:stdin]
      @options[:stdin] = filepath.source
      result = yield
      filepath.source = @options[:stdin]
      @options[:stdin] = stdin_backup

      result
    else
      yield
    end
  end
end

module RunnerModifier
  include HijackSTDIN

  private

  def find_target_files(paths)
    file_paths, strs = paths.partition { |path| path.is_a?(FilePath) }

    if file_paths.empty?
      super(strs)
    elsif strs.empty?
      file_paths
    else
      file_paths + super(strs)
    end
  end

  def get_processed_source(file)
    hijack_stdin_opt(file) do
      super
    end
  end

  def cached_run?
    false
  end
end

RuboCop::Runner.prepend RunnerModifier

module TeamModifier
  include HijackSTDIN

  def autocorrect(buffer, cops)
    code = cops[0].processed_source.path
    hijack_stdin_opt(code) do
      super
    end
  end
end

RuboCop::Cop::Team.prepend TeamModifier

cli = RuboCop::CLI.new
source_code = FilePath.new("sample.rb", "[1 , 2]")
cli.run(["--auto-correct", source_code])
puts source_code.source
Inspecting 1 file
C

Offenses:

sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma.
[1 , 2]
  ^
sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing.
[1 , 2]
       

1 file inspected, 2 offenses detected, 2 offenses corrected
[1, 2]

問題なさそうだ。 おまけに ruby_version を得るための重複したコードもなくなった。 これでオートコレクトの準備はできた。

RDoc 内のコードをオートコレクト

C のコードからコメントを抽出する方法は先に書いたとおり。 ここから更に Ruby のコードを抜き出し、修正後に元に戻さなければならないのだが、泥臭くテキスト処理をするだけなのでばっさりカット。

気になる方は冒頭に記載した rdoc_rubocop gem の実装を見てください。 RDocRuboCop::RDoc あたりが該当します。

Ruby での統計処理は R より遅いものの結構速い

Rails アプリでまとまった数の DB のレコードをロードし、統計処理をして結果を DB に記録するということをしたところ、完了するまで10時間以上かかってしまいかなり遅いと感じた。 改善したいと思いコードを見直そうとしたのだが、これを R で実装したらかなり改善できるのではないかと思った。 既存のコードを R に移植するのはそこそこ時間がかかりそうだったので、簡略化した処理で実行時間の比較をし、

比較方法

  • DB から 10 万件のレコードを読み、単純移動平均 (n = 25) を計算して DB に記録する
  • 実行時間は time コマンドの結果とする

環境および実装

DB

テストデータのテーブル定義は以下

-- 統計対象
CREATE TABLE `test_data` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `value` float NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

-- 単純移動平均の結果
CREATE TABLE `smas` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `value` float NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
Ruby
require "active_record"
require "activerecord-import"

class TestData < ActiveRecord::Base
end

class SMA < ActiveRecord::Base
  def self.calculate(test_data, n)
    0.upto(test_data.size - 1 - n).map do |i|
      [test_data[i, n].sum(&:value) / n]
    end
  end
end

ActiveRecord::Base.establish_connection(adapter: "mysql2", database: "testdb", username: "user", password: "password")

columns = %w(value)
test_data = TestData.all
sma_values = SMA.calculate(test_data, 25).compact
SMA.import(columns, sma_values, validate: false)

R

  • R 3.5.1
  • RMySQL
library(RMySQL)

conn <- dbConnect(dbDriver("MySQL"), dbname = "testdb", user = "user", password = "password")
dat <- dbReadTable(conn, "test_data")
sma <- na.omit(filter(dat$value, rep(1:1, 25)))

valuesSQL <- paste(
  "(NULL, ",
  sma,
  ")",
  sep = "",
  collapse = ","
)
insertSQL <- paste(
  "INSERT INTO `smas` (`id`, `value`) VALUES",
  valuesSQL
)

dbSendQuery(conn, insertSQL)

dbDisconnect(conn)

実行結果

今回試した環境では、Ruby での実装は R の 3.5 倍ほどの時間がかかることがわかった。

> time bundle exec ruby calculate.rb 

real    0m4.210s
user    0m3.175s
sys     0m0.129s
> time Rscript --vanilla calculate.R 
 要求されたパッケージ DBI をロード中です 
<MySQLResult:-1925505208,0,1>
[1] TRUE
 警告メッセージ: 
Closing open result sets 

real    0m1.273s
user    0m0.576s
sys     0m0.031s

なお Ruby においては bundle exec ruby -r active_record -r activerecord-import -e "" だけでも 0.5 秒程度かかるので、実質的な差はより少ないと考えられる。

まとめ

Ruby による実装では R よりも時間がかかったものの、R の 3.5 倍程度の差にとどまるということがわかった。 これは処理対象となるレコードが 10 万件のものなので、レコード数や性能要件によっては Ruby で実装しても問題ないと判断することも多いと思われる。

また Ruby でも ActiveRecord モデルを使用せずレコードのデータを Array の Array でロード・処理することでより高速化はできるかもしれない。

なおここで使用したコードは GitHub に push した (nowlinuxing/activerecord-vs-R) ので興味のある方はどうぞ。

久しぶりにPCを自作した

直近2年ほどはメインマシンとしてDELLのノートPC xps13を使っていた。しかしメモリ8GB、CPUコア2つというスペックでは仮想マシンを起動するとパフォーマンスにかなり不満を感じるようになったのと、AMDRyzenによるCPUの多コア戦争勃発により自作PCにかなり魅力を感じるようになってきたので作ってみた。

要件

PCに求めるものは以下の通り。

  • Rails、Dockerによる開発がメイン
    • 普段は長時間軽負荷 + 短時間高負荷と思われるのでIdle時を省電力に
  • IntelAMDの競争が激化し陳腐化が進みそうなので、ハイエンドよりはコストパフォーマンス重視(特にCPUとマザーボード)
  • 仮想マシンを複数起動しても困らないようメモリ多め
  • コンパクト
    • GPUは当面不要だし、ストレージや光学ドライブなどは必要に応じてUSB接続するつもりなので容積は必要最小限
  • VGA出力は4k2つ以上。そのうち少なくとも1つはHDMI
    • できればHDCP対応
  • WindowsなしでBIOSアップデートできること
    • Linuxで運用を想定
  • ギガビットLAN

PCの構成

まずCPUを選択する。 シングルスレッド性能は当面それほど必要なさそうなのでTDPは65w以下で検討。RyzenはIdle時の消費電力が低くならないようなのでパス。 これらの中からコスト・ワットパフォーマンスに優れたCPUということでCore i5を使用することにした。 他のパーツは適当に。

OSはUbuntu 18.04。 ディスプレイ、マウス、キーボードは手持ちのものを流用。

結果

ベンチマークなどは用意していないので、わかる範囲で。

パフォーマンス

ruby-build で2.6.0-preview1 をインストールしてみた結果

$ time rbenv install 2.6.0-preview1
Downloading ruby-2.6.0-preview1.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.0-preview1.tar.bz2
Installing ruby-2.6.0-preview1...
Installed ruby-2.6.0-preview1 to /home/masaoo/.rbenv/versions/2.6.0-preview1


real    1m54.284s
user    4m10.769s
sys 0m26.207s
消費電力
  • 起動時は 25w ほど
  • サスペンド時は 2w
  • OS 起動後の Idle では 15~16w
  • Ruby 2.6.0-preview1 インストール時では、一瞬 57w ほどになるもののそれ以外では 25w 程度

Ubuntu のインストール時になぜかフリーズしてしまう現象が発生したので、電力不足を疑いTurbo BoostをDisableにしたら無事完了した。 上記計測中もこの設定はそのまま。

これ以外の設定もいじればもっと省電力にできるかもしれないが、構成を考える際の参考にした5chのスレではZ370M-ITX/acの省電力設定には限界がありそうという考察もあるので難しいかも。

おわりに

費用は合計で11万円ほどだったと思う。お手頃な価格でそれなりにパワフルなPCができた。

私が自作PCに魅力を感じるのはパーツ交換の自由度が高い、削るところと集中するところを選べる(事務用PCとゲーミングPCの二択みたいなことにならない)というのもあるが、割と大きいのはLinuxをインストールしたときのトラブルが少ない、ということだろうか。 というのも以前使用していたノートPCではVGAやACPI、Wifiあたりに問題が出ることが多く、ドライバの差し替えや設定の調整などでかなり面倒な作業が必要だった。

その点今回のPCはUbuntuインストーラで全てが問題なく動作したため、特別な設定はほとんど必要なくとても楽だった。 (SSD用の設定変更はしたがこれは自作、ノート関係なく必要なので例外とする) PCの自作なんて10数年ぶり(先代の自作機はDuron)だったのでキャッチアップがそこそこ必要だったけど、それもまた楽しかった。

VAIO F の HDD を交換した

2010 年のモデルの VAIO を使用しているのですが、随分前から HDD が手狭になってきたところに Windows 7 が起動しなくなってしまうという状況に陥ってしまったため、リカバリついでに HDD を大容量のものに交換しました。
交換したのは HGST の 1.5GB モデル。

さて、交換後に DVD でリカバリを行うと、完了後の Windows 7 再起動時に「このコンピュータのハードウェアで動作するようにwindowsを構成できませんでした」と表示され、それ以上進めることができません。
HDD は 2011 年頃から AFT に切り替わってきたそうで、非 AFT なシステムからアクセスするためにはドライバが対応している必要があるようですがこの VAIO F のリカバリ DVD で復元される Windows 7 はおそらく AFT 非対応なため、HDD にアクセスできなくなるという状態になるようです。
この対策を調べたところ、以下のページを見つけました。

価格.com - 『旧型VaioのHDD交換方法案 4Kバイトセクタ問題対策(参考まで)』 SONY VAIO Fシリーズ VPCF128FJ/B のクチコミ掲示板

この方法に従い

  1. リカバリー DVD でリカバリ
  2. 再起動後、Ubuntu の DVD で起動 (Windows を起動させてしまうとこれ以降の対策が無効になってしまうため注意)
  3. ドライバを差し替えるため、Windows パーティションの \Windows\System32\drivers\iaStor.sys を Download Intel® Rapid Storage Technology (RAID) for Legacy Intel® Desktop Boards からダウンロードできる STOR_all64_f6flpy_9.6.0.1014_PV.zip の ioStor.sys で上書き
  4. Ubuntu を終了しリカバリを継続

で無事リカバリすることができました。

VAIO FのUbuntuを13.04から13.10にした

アップグレードはいつも通りdo-release-upgradeでOKなのですが、輝度調整ができなくなる現象が再発。
そこでNVIDIAのドライバをインストールしなおそうとしたところ、エラーでインストールできない現象が発生。調べてみるとドライバのコンパイルが失敗しているらしいことが判明。
この辺を参照してまた輝度調整できるようにできました。

Ubuntu 13.04 をインストールした VAIO F で輝度を調整できるようにする

VAIO F (VPCF118FJ) にUbuntu 13.04をインストールして使用しています。
VPCF118FJにはNVIDIAGeForce 310Mが使用されているのですが、NVIDIAのドライバをインストールするとX Windowで輝度調整ができなくなり、最大輝度になってしまいます。
一応、nvidia-settingsという設定ツールを使えば調整できますが、ファンクションキーで調整するのに比べると面倒です。
そこで、nvidiablを使用して調整できるようにしてみました。
快適。