LoginSignup
71
51

More than 5 years have passed since last update.

RailsのCSVのところをRustで処理したら早かった

Last updated at Posted at 2017-01-30

背景

RailsでCSVを返す時、10万データくらいのものを普通に処理するとメモリを食い潰したり、処理が遅かったりする。
そこで解決策を探していたら、RustでRailsの代わりにCSVを作るという素晴らしい記事に出会った。
この記事はPostgresやNginxを使ってたが、私はMySQLを使っており、Railsのアプリケーション内で完結したかったので、参考にしながら試験的に作って見た。

お断り

Railsをすでに理解していることを前提に進めます。
初心者の方へ環境構築周りを最後の方におまけとして書いておきますので参考にしてください。
筆者はRust初心者ですのでアドバイスを頂けるととても助かります。

環境

  • Ruby 2.3.3
  • Rust 1.14.0
  • Rails 5.0.1

Gemfileは基本デフォルト

Gemfile
source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end


gem 'rails', '~> 5.0.1'
gem 'mysql2', '>= 0.3.18', '< 0.5'
gem 'puma', '~> 3.0'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.2'

gem 'jquery-rails'
gem 'turbolinks', '~> 5'
gem 'jbuilder', '~> 2.5'

gem 'ffi'

group :development, :test do
  gem 'byebug', platform: :mri
end

group :development do
  gem 'web-console', '>= 3.3.0'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

cargoの設定は以下の通り(CSVクレートを使ったバージョンは最後の方にある追記を参考にしてください)

Cargo.toml
[dependencies]
mysql = "9.0.1"
libc = "0.2.0"

[lib]
name = "csv"
crate-type = ["dylib"]

Rails側の設定

bundle exec rails new . -BTf -d mysql でnewした時の設定とする。

データの作成

bundle exec rails g model csv_tests でModelを作成。
migrationファイルは以下のようにする。

2017__create_csv_tests.rb
class CreateCsvTests < ActiveRecord::Migration[5.0]
  def change
    create_table :csv_tests do |t|
      t.string :name
      t.string :hoge
      t.string :foo
      t.string :hogefoo
      t.string :hogehoge
      t.string :foofoo
      t.string :namehoge
      t.string :namefoo
      t.string :namehogefoo
      t.string :namehogehoge
      t.string :namefoofoo

      t.timestamps
    end
  end
end

データを入れるため。seeds.rbファイルを以下のようにする。

db/seeds.rb
500_000.times do |i|
  t = CsvTest.new
  t.name = (1...100).map{ (65 + rand(26)).chr }.join
  t.hoge = (1...100).map{ (65 + rand(26)).chr }.join
  t.foo = (1...100).map{ (65 + rand(26)).chr }.join
  t.hogefoo = (1...100).map{ (65 + rand(26)).chr }.join
  t.hogehoge = (1...100).map{ (65 + rand(26)).chr }.join
  t.foofoo = (1...100).map{ (65 + rand(26)).chr }.join
  t.namehoge = (1...100).map{ (65 + rand(26)).chr }.join
  t.namefoo = (1...100).map{ (65 + rand(26)).chr }.join
  t.namehogefoo = (1...100).map{ (65 + rand(26)).chr }.join
  t.namehogehoge = (1...100).map{ (65 + rand(26)).chr }.join
  t.namefoofoo = (1...100).map{ (65 + rand(26)).chr }.join
  t.save!
  if(i % 1000 == 0)
    puts "#{i}個保存完了"
  end
end

ランダムな文字列を作る際、Rubyでランダムな文字列を生成する方法を参考にしました。

最後にDBの接続設定を修正する。

config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5
  username: root
  password:
  socket: /tmp/mysql.sock

development:
  <<: *default
  database: csv_test_development

ここまで設定したら以下のコマンドを実行する。

$ bundle exec rails db:create
$ bundle exec rails db:migrate
$ bundle exec rails db:seed
# seedは50万件のデータを入れ込むのでかなり時間がかかる

ルーティングからコントローラの設定

ルーティングの設定は

config/routes.rb
Rails.application.routes.draw do
  root to: 'csv#index'
  get :ruby, to: 'csv#ruby'
  get :rust, to: 'csv#rust'
end

次にコントローラの設定をする。

app/controllers/csv_controller.rb
require 'csv'
require 'ffi'

class CsvController < ApplicationController
  def index
  end

  def ruby
    start_time = Time.now

    ruby_csv = CSV.generate do |csv|
      data_column = CsvTest.column_names
      data_column.delete("created_at")
      data_column.delete("updated_at")
      csv << data_column
      CsvTest.all.each do |data|
        csv << data.attributes.values_at(*data_column)
      end
    end

    puts '------------------------------------------------------------'
    puts '処理にかかった時間'
    puts Time.now - start_time
    puts '------------------------------------------------------------'

    respond_to do |format|
      format.csv { send_data ruby_csv }
    end
  end

  module CSVmaker
    extend FFI::Library
    ffi_lib Rails.root + 'rust/target/release/libcsv.dylib'
    attach_function :make_csv, [], :string
  end

  def rust
    start_time = Time.now

    rust_csv = CSVmaker.make_csv

    puts '------------------------------------------------------------'
    puts '処理にかかった時間'
    puts Time.now - start_time
    puts '------------------------------------------------------------'

    respond_to do |format|
      format.csv { send_data rust_csv }
    end
  end
end

コントローラ内でrequireやmoduleを定義するのはナンセンスだが、実験で作っているので今回はこのようにした。

今回のような単純な測定だとCPU状態とかに処理時間が依存したりするのであまり良くはないが、処理時間の差はかなりの差になると思ったので単純な測定にしている。

module CSVmaker は後々作るRustの関数を使うものだと考えてくれれば良い(詳しくはRuby ffiへ)。

libcsv.dylib これはOSによって異なると思う。これに関してはRust側の説明の時に触れる。

Viewの設定

CSV周りの機能などはコントローラにもたせたのでほとんどやることはない。

app/views/csv/index.html.erb
<%= link_to 'csv download(ver: ruby)', ruby_path(format: :csv) %>
<%= link_to 'csv download(ver: rust)', rust_path(format: :csv) %>

これでRails側の設定は完了。

Rust側

Railsのルートディレクトリで cargo new rust とする。
そして生成されたディレクトのcargo.tomlを次のように修正する。

rust/Cargo.toml
[package]
ここは特にいじらない

[dependencies]
mysql = "9.0.1"
libc = "0.2.0"

[lib]
name = "csv"
crate-type = ["dylib"]

次に rust/src/lib.rs を次のようにする。

rust/src/lib.rs
extern crate mysql;
extern crate libc;

use libc::*;
use std::ffi::{CString};

pub struct CsvTest {
    id: i32,
    name: String,
    hoge: String,
    foo: String,
    hogefoo: String,
    hogehoge: String,
    foofoo: String,
    namehoge: String,
    namefoo: String,
    namehogefoo: String,
    namehogehoge: String,
    namefoofoo: String,
}

#[no_mangle]
pub extern fn make_csv() -> *const c_char {
    // DBの接続
    let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();

    // SELECTクエリ
    let select_all: Vec<CsvTest> =
        pool.prep_exec("SELECT id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo from csv_tests", ())
        .map(|result| {
            result.map(|x| x.unwrap()).map(|row| {
                let (id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo) = mysql::from_row(row);
                CsvTest {
                    id: id,
                    name: name,
                    hoge: hoge,
                    foo: foo,
                    hogefoo: hogefoo,
                    hogehoge: hogehoge,
                    foofoo: foofoo,
                    namehoge: namehoge,
                    namefoo: namefoo,
                    namehogefoo: namehogefoo,
                    namehogehoge: namehogehoge,
                    namefoofoo: namefoofoo,
                }
            }).collect()
        }).unwrap();

    // CSVの作成
    let mut rust_csv: String;
    rust_csv = format!("\"id\",\"name\",\"hoge\",\"foo\",\"hogefoo\",\"hogehoge\",\"foofoo\",\"namehoge\",\"namefoo\",\"namehogefoo\",\"namehogehoge\",\"namefoofoo\"\n");
    for i in &select_all {
        let record: String = format!("\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"\n",
                                     i.id.to_string(),
                                     i.name,
                                     i.hoge,
                                     i.foo,
                                     i.hogefoo,
                                     i.hogehoge,
                                     i.foofoo,
                                     i.namehoge,
                                     i.namefoo,
                                     i.namehogefoo,
                                     i.namehogehoge,
                                     i.namefoofoo
        );

        rust_csv.push_str(&record);
    }

    CString::new(rust_csv).unwrap().into_raw()
}

structは構造体なので説明はRustドキュメントにお願いする。

make_csv関数について

let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();

これはDBへの接続を行う。環境によってはパスワードが必要になったりなどあると思うのでnew以下を個人の設定に合わせて修正する必要がある。

let select_all = ...

ここは公式ドキュメントをほぼ真似ている。
詳しくは http://blackbeam.org/doc/mysql/index.html

// CSVの作成 以下

こちらはCSVのフォーマットに合わして文字列を生成している。

CString::new(rust_csv).unwrap().into_raw()

こちらはCの文字列に修正してRubyに渡すためのものである。
pub extern fn make_csv() -> *const c_char
これの返り値の型をstringにするとRubyで呼び出した時セグメントフォルトとなる。

build

rustディレクトリで cargo build --release とする。
完了したら ls target/release として libcsv.dylib のようなファイルがあるか確認をする。
ただし、これはOSによって拡張子が違ったりすると思う。
Macではlibcsv.dylibだった。

これをさきほどRailsのコントローラ内に定義してmoduleで読み込む。

    module CSVmaker
    extend FFI::Library
    ffi_lib Rails.root + 'rust/target/release/libcsv.dylib'
    attach_function :make_csv, [], :string
  end

こうすることでRustの関数をRubyで使用することができる。

実験

以上で設定は完了したので bundle exec rails s として、サーバを立ち上げる。

スクリーンショット 2017-01-30 13.47.46.png

このような画面が出てくるので後はリンクを押す。
無事CSVがダウンロードできたらRailsを立ち上げたターミナルに行くと何秒かかったかを見る事ができる。

------------------------------------------------------------
処理にかかった時間
10.226519
------------------------------------------------------------
------------------------------------------------------------
処理にかかった時間
146.68222
------------------------------------------------------------

上がRustの処理で下がRubyの処理。

まとめ

Rubyで50万レコードでのCSV処理をした時メモリをバカ食いしていたので大きいデータに対しては極力Rubyを避けたほうがいいかもしれません。
今回は実験的に作ってみましたが、本格的に作れば一部の処理をRustに任せるのはありだと思いました。

おまけ(Railsの環境構築)

まず、rbenvなどでruby2.3.3を入れます。
次に gem install bundle とします。

お好きなディレクトリで

Gemfile
source 'https://rubygems.org'

gem 'rails', '~> 5.0.1'

を作ります。
bundle install --path vendor/bundle とします。
pathを指定した際、gitignoreで vendor/bundle としてからGitに入れましょう。

bundle exec rails new . -BTf -d mysql とすれば今回使う環境の出来上がりです。

bundleは gem install とは異なるので rails s などのようなコマンドを打つ時は頭に bundle exec とする必要があります。

追記2017/01/30 19:00

CSVのところをRubyでは標準ライブラリを使っていたが、Rustでは独自に処理した形となっているのでRubyの方をRustに合わせてみた。

app/controllers/csv_controller.rb
    ruby_csv = "\"id\",\"name\",\"hoge\",\"foo\",\"hogefoo\",\"hogehoge\",\"foofoo\",\"namehoge\",\"namefoo\",\"namehogefoo\",\"namehogehoge\",\"namefoofoo\"\n"
    CsvTest.all.each do |data|
      ruby_csv << "\"#{data.id}\",\"#{data.name}\",\"#{data.hoge}\",\"#{data.foo}\",\"#{data.hogefoo}\",\"#{data.hogehoge}\",\"#{data.foofoo}\",\"#{data.namehoge}\",\"#{data.namefoo}\",\"#{data.namehogefoo}\",\"#{data.namehogehoge}\",\"#{data.namefoofoo}\"\n"
    end

CSV.generate の部分を上記のように置き換えました。
こちらで実験したところ

------------------------------------------------------------
処理にかかった時間
73.224735
------------------------------------------------------------

となりました。

公式CSVライブラリドキュメントに書いてある

不正な CSV データを与えたくない。あるフィールドが不正であることが確定す るのはファイルを全て読み込んだ後です。これは多くの時間やメモリを消費し ます。

ここの部分の処理のためメモリや処理時間を消費していたみたいです。

追記2017/02/02 14:00

ご指摘いただいたCSVクレートを用いての処理ver

Rails側の変更点はありません。

Cargo.tomlに追記する必要があるので以下のように設定します。

Cargo.toml
[dependencies]
mysql = "9.0.1"
libc = "0.2.0"
csv = "0.14"
rustc-serialize = "0.3.22"

[lib]
name = "csv"
crate-type = ["dylib"]

次に rust/src/lib.rs の修正をします。

rust/src/lib.rs
extern crate mysql;
extern crate libc;
extern crate csv;
extern crate rustc_serialize;

use libc::*;
use std::ffi::{CString};

#[derive(RustcEncodable)]
pub struct CsvTest {
    id: i32,
    name: String,
    hoge: String,
    foo: String,
    hogefoo: String,
    hogehoge: String,
    foofoo: String,
    namehoge: String,
    namefoo: String,
    namehogefoo: String,
    namehogehoge: String,
    namefoofoo: String,
}

#[no_mangle]
pub extern fn make_csv() -> *const c_char {
    // DBの接続
    let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();

    // SELECT
    let select_all: Vec<CsvTest> =
        pool.prep_exec("SELECT id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo from csv_tests", ())
        .map(|result| {
            result.map(|x| x.unwrap()).map(|row| {
                let (id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo) = mysql::from_row(row);
                CsvTest {
                    id: id,
                    name: name,
                    hoge: hoge,
                    foo: foo,
                    hogefoo: hogefoo,
                    hogehoge: hogehoge,
                    foofoo: foofoo,
                    namehoge: namehoge,
                    namefoo: namefoo,
                    namehogefoo: namehogefoo,
                    namehogehoge: namehogehoge,
                    namefoofoo: namefoofoo,
                }
            }).collect()
        }).unwrap();

    // CSVの作成
    let mut rust_csv = csv::Writer::from_memory();
    let header = ("id", "name", "hoge", "foo", "hogefoo", "hogehoge", "foofoo", "namehoge", "namefoo", "namehogefoo", "namehogehoge", "namefoofoo");
    rust_csv.encode(header);
    for i in &select_all {
        rust_csv.encode(i);
    }

    CString::new(rust_csv.as_string()).unwrap().into_raw()
}

主な変更点はCSVの作成のところです。
CSVクレートを使うために

extern crate csv;
extern crate rustc_serialize;

とファイルの先頭に書き、構造体の上に #[derive(RustcEncodable)] をつけます。
あとは CSVクレートの公式ドキュメント を参考にしながらCSVの作成部分を変更しました。

修正が完了しましたら rust ディレクトリで $ cargo build --release として Rails サーバを立ち上げるだけです。

こちらでCSVを落としたところ

------------------------------------------------------------
処理にかかった時間
10.199653
------------------------------------------------------------

となりました。

今回の測定法がCPUの状態などに計測時間が依存していることを考慮しても十分に早くCSV処理が出来ていると考えられます。
Railsでボトルネックになりやすいビジネスロジック部分をRustに任せてみると良さそうかもしれません。

71
51
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
71
51