大量のテキストからトークンを切り出す際に、トークンをキャッシュしてメモリ使用量を抑える

大量のテキストからトークンを切り出す場合(形態素解析など)に、同じ内容のトークンが大量に発生することがあります。

このトークンが不変なオブジェクトであるならば、同じ内容のトークンは1つのオブジェクトを使いまわす、すなわちキャッシュすることによって、メモリ使用量を減らせそうだなということで試してみました。

コード

Javaでの検証となります。 アルファベット以外を区切り文字とし、小文字にしたアルファベットの文字列をトークンとするといった簡易的なコードで検証します。

キャッシュなしは下記のようなコードになります。

public class Tokenizer {

    public List<String> tokenize(String text) {

        String[] tokens = text.split("[^a-zA-Z]+");

        return Stream.of(tokens)
                .map(String::toLowerCase)
                .collect(Collectors.toList());
    }
}

キャッシュありでは、トークンのキャッシュのために、HashMapを使用します。Key、Valueともにトークンが入ります。(マルチスレッドで使用されることを想定していないコードになっているので注意)

public class CachedTokenizer {

    private HashMap<String, String> tokenCache = new HashMap<>();

    public List<String> tokenize(String text) {

        String[] tokens = text.split("[^a-zA-Z]+");

        return Stream.of(tokens)
                .map(String::toLowerCase)
                .map(token -> tokenCache.computeIfAbsent(token, key -> key))
                .collect(Collectors.toList());
    }
}

検証結果

英文のテキストを使用して検証を行いました。 言語処理100本ノック 2015 にて配布されている下記コーパスの先頭50万行を使用させていただきました。

  • http://www.cl.ecei.tohoku.ac.jp/nlp100/data/enwiki-20150112-400-r10-105752.txt.bz2

    2015年1月12日時点のWikipedia記事データベースのダンプ(英語)うち,約400語以上で構成される記事の中から,ランダムに1/10サンプリングした105,752記事のテキストをbzip2形式で圧縮したものです.このファイルはクリエイティブ・コモンズ 表示-継承 3.0 非移植のライセンスで配布されています.

サイズとしては140MBとなり、出現トークン数は重複ありで2300万、重複を取り除くと28万ほどとなりました。

リソースの状況は、Java Flight Recorderを使用して確認しています。

キャッシュなし

キャッシュ無しの場合の実行結果です。 メモリは最大で1.45GBまで使用されています。処理時間は27秒ほどでした。

f:id:onozaty:20171029023704p:plain

キャッシュあり

キャッシュありの場合の実行結果です。 メモリは最大で792MBまで使用されています。処理時間は10秒ほどでした。

メモリ使用量で約半分、処理時間も半分以下になっています。処理時間が短縮されたのはGCにかかる時間が減ったためのようです。(使用するメモリ量も減ったので)

f:id:onozaty:20171029024019p:plain

追記@2017-10-30

と教えていただいたので、internを使って試してみたところ、同じような効果を得られました。

f:id:onozaty:20171031003248p:plain

まとめ

同じトークンの出現頻度が多ければ、キャッシュすることによってメモリ使用量を抑える効果があることがわかりました。

同じトークンが少ない場合には、効果が薄い(逆にHashMap分余計にメモリを使うことになりかねない)ため、扱うデータの性質などによって、キャッシュするかどうか考えたほうがよさそうです。(小さなテキストでも試しましたが、ほとんど効果を得られませんでした)

Firefoxでbreak-insideは対応していないけど、page-break-insideで同じ効果を得られる(Firefox 56で確認)

CSSのcolumn-countなどを使って段組した場合、要素の途中で折り返しをして欲しくなくて、break-inside: avoid;を指定していました。

Chromeはこれで意図した動きになりましたが、Firefox(確認したのはFirefox 56)ではサポートされておらず、折り返しを禁止することができませんでした。

どうしようかと悩んでいたところ、似たようなプロパティとしてpage-break-insideがあることを知り、試してみたところpage-break-inside: avoid;で同じ結果を得ることができました。

ShortcutKey2URL for Chrome というGoogle Chrome向けの拡張機能を公開しました

ShortcutKey2URL for Chrome という、Google Chrome向けの拡張機能を公開しました。

ShortcutKey2URL は、ショートカットキーを使用してURLを開いたり、移動したり、JavaScriptを実行できる拡張機能です。

スタートアップキー(デフォルトはCtrl+Period、MacだけCommand+Comma)であらかじめ設定しておいた動作の一覧を表示し、次のキーでその動作を実行します。この2段階動作が、ShortcutKey2URLの特徴です。

f:id:onozaty:20171018003430p:plain

キーとして設定しておける文字は1文字に限定されません。2文字以上で設定することも可能です。これによって、キーの自由度が高まります。たとえば、Googleのサービスは先頭1文字は"G"として、2文字目で各サービスを表すといったような、階層を持たせることができます。

また動作として設定できるものには、下記のようなものがあります。

  • URLへの移動。既に開いているURLの場合は、そのタブへ移動し、開かれていない場合には、新しいタブとして開く。
  • 新規タブとしてURLを開く。
  • 現在のタブにURLを開く。
  • 現在のタブで指定したJavaScriptを実行する。
  • 新規タブとしてURLを開いて、その後に指定したJavaScriptを実行する。

ぜひお試しください!

FirefoxのShortcutKey2URL

ShortcutKey2URLは、もともとFirefoxのアドオンとして、10年くらい前に公開したものです。

自分自身でもずっと愛用していて、欠かせないアドオンです。

Firefoxの57にて、WebExtensions対応のアドオン以外は無効になるため、このままだとShortcutKey2URLは使えなくなってしまいます。他の利用者の方からも、WebExtensions対応して欲しい!といった要望をもらったので、1ヶ月ほど前からコツコツとWebExtensions版を作り始めました。

ただ、いきなりFirefoxのWebExtensionsのバグを踏んでしまい、その対応まっていると先に進まない、、ということで、Google Chromeの拡張機能で同等のものを作り始めて、今回のリリースとなりました。 同じ名前ではありますが、一から作り直す形になっています。既存のコードはまったく役に立たないというか、同じ方法では実現できないため、まずは実現方法から考え直した感じです。

Firefox版も、これから対応を始めて、Firefox57のリリースまでには間に合わせたいと思っています。

FirefoxのWebExtensionsにおけるショートカットキー(commands)の重複について

FirefoxのWebExtensionsで、ショートカットキーはmanifest.jsoncommandsで定義します。

"commands": {
  "toggle-feature": {
    "suggested_key": {
      "default": "Ctrl+Shift+Y",
      "linux": "Ctrl+Shift+U"
    },
    "description": "Send a 'toggle-feature' event"
  }
}

suggested_keyにそのコマンドに対するショートカットキーを書くのですが、重複したときどうなるのかわからなかったので、調べてみました。

検証コード

下記のようなmanifest.jsonを用意して、拡張の中でショートカットキーを重複させたものと、標準である機能に対するショートカットキーと重複させてみて確認しました。

{
  "description": "",
  "manifest_version": 2,
  "name": "",
  "version": "1.0",
  "homepage_url": "https://github.com/onozaty/sandbox",
  "background": {
    "scripts": ["background.js"]
  },

  "commands": {
    "command-a-1": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y"
      }
    },
    "command-a-2": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y"
      }
    },
    "command-a-3": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y"
      }
    },
    "command-b": {
      "suggested_key": {
        "default": "Ctrl+A"
      }
    },
    "command-c": {
      "suggested_key": {
        "default": "Ctrl+1"
      }
    }
  }
}

background.jsはこんな感じで、最初にcommandの一覧を出力し、ショートカットキーによって受け取ったcommandを出力するようにしています。(MDNのサンプルを参考に)

var gettingAllCommands = browser.commands.getAll();
gettingAllCommands.then((commands) => {
  for (let command of commands) {
    console.log(command);
  }
});

browser.commands.onCommand.addListener((command) => {
  console.log("onCommand event received for message: ", command);
});

結果

commandの一覧としては、manifest.jsonに記載した形のままで取得されました。

f:id:onozaty:20170916144703p:plain

同一拡張内での重複

拡張内で重複しているショートカットキーの"Ctrl+Shift+Y"を押下すると、先頭のコマンドのみ呼ばれました。

f:id:onozaty:20170916150127p:plain

標準のショートカットキーとの重複

標準のショートカットキーと重複した"Ctrl+A"、"Ctrl+1"を押下したときも、問題なく拡張で定義したコマンドが呼ばれました。標準のショートカットキーによる動作はしなかったので、標準のショートカットキーを拡張で奪えるようです。

f:id:onozaty:20170916150341p:plain

拡張間で重複

同じ内容の拡張を2つ登録したところ、当然片方しか呼ばれませんでした。

Chromeだと拡張で定義したショートカットキーを変更できるのですが、Firefoxだと同様の機能を見つけられていません。結局そういったものが無いと、ショートカットキーが競合してしまい、拡張機能の利便性損なうので、ぜひ入って欲しいですね。 (自分が見つけられてないだけかも?)

PostgreSQLのSKIP LOCKEDを使ってテーブルをキューとして使用する

SKIP LOCKED

SKIP LOCKED は PostgreSQL 9.5 から入った新機能です。

これを使うと、FOR UPDATEの際に別トランザクションによって行ロックが取得されているレコードを除外することができます。すなわち、他のトランザクションによる行ロックが解除されるのを待つ必要がなくなります。

サンプル

idというカラムを持つidsというテーブルを作成し、3レコード作成しておきます。

CREATE TABLE ids AS SELECT generate_series(1, 3) AS id;
testdb=> CREATE TABLE ids AS SELECT generate_series(1, 3) AS id;
SELECT 3
testdb=> SELECT * FROM ids;
 id
----
  1
  2
  3
(3 rows)

1つめのトランザクションでは、FOR UPDATEでid=1の行ロックを取得します。

BEGIN;
SELECT * FROM ids WHERE id = 1 FOR UPDATE;
testdb=> BEGIN;
BEGIN
testdb=> SELECT * FROM ids WHERE id = 1 FOR UPDATE;
 id
----
  1
(1 row)

2つめのトランザクションでは、全行をFOR UPDATEで取得します。id=1のレコードがロックされているので、全行取ろうとしてもロックが競合して取得できません。(競合することがわかるようにNOWAITで待ち合わせないようにしています)

BEGIN;
SELECT * FROM ids FOR UPDATE NOWAIT;
testdb=> BEGIN;
BEGIN
testdb=> SELECT * FROM ids FOR UPDATE NOWAIT;
ERROR:  could not obtain lock on row in relation "ids"

このクエリにSKIP LOCKEDを付けると、ロックされているid=1のレコードを除外して取得できるようになります。

SELECT * FROM ids FOR UPDATE SKIP LOCKED;
testdb=> SELECT * FROM ids FOR UPDATE SKIP LOCKED;
 id
----
  2
  3
(2 rows)

SKIP LOCKEDを利用してキューを実装

これが便利なのは、テーブルをキューとして使うときだと思います。というか、それ以外で使い道を思いついていません。

SKIP LOCKEDを使うことによって、他のトランザクションの完了を待たずにロックしていない行を取得できるので、キューとしての性能を高めることが出来ます。

ということで、キューとしての使用方法を試してみます。queuesというテーブルを作り、idの昇順で取り出すことにします。

CREATE TABLE queues (
   id SERIAL,
   message TEXT,
   PRIMARY KEY(id)
);

INSERT INTO queues(message) SELECT 'message' || id FROM generate_series(1, 3) AS id;
testdb=> CREATE TABLE queues (
testdb(>   id SERIAL,
testdb(>   message TEXT,
testdb(>   PRIMARY KEY(id)
testdb(> );
CREATE TABLE
testdb=> INSERT INTO queues(message) SELECT 'message' || id FROM generate_series(1, 3) AS id;
INSERT 0 3
testdb=> SELECT * FROM queues;
 id | message
----+----------
  1 | message1
  2 | message2
  3 | message3
(3 rows)

ORDER BYLIMIT 1で優先度順で先頭の1件取り出し、FOR UPDATE SKIP LOCKEDを付けてあげるだけです。

1つめのトランザクションで実行します。

BEGIN;
SELECT * FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
testdb=> BEGIN;
BEGIN
testdb=> SELECT * FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
 id | message
----+----------
  1 | message1
(1 row)

1つめのトランザクションが完了する前に、2つめのトランザクションで同じSQLを実行します。1つめのトランザクションの完了を待たされること無く、LOCKされていない行の中から優先度が高い行を取得できます。

BEGIN;
SELECT * FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
testdb=> BEGIN;
BEGIN
testdb=> SELECT * FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
 id | message
----+----------
  2 | message2
(1 row)

ただ、これだとqueuesテーブルから取り出したレコードを消せていないので、取り出した行を別途DELETEしてあげる必要があります。

そのまま同一のトランザクション内で取り出したレコードに対してDELETEするといった方法もありますが、RETURNINGを使うことによって、該当行の内容取得とDELETEをひとつのクエリで実行できます。

DELETE FROM queues 
  WHERE id = (SELECT id FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING *;
testdb=> DELETE FROM queues 
testdb->   WHERE id = (SELECT id FROM queues ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)
testdb-> RETURNING *;
 id | message
----+----------
  2 | message2
(1 row)

DELETE 1

RETURNINGは、INSERTUPDATEDELETEで処理したレコードの情報を取得する構文です。シーケンスによって払い出された値をINSERTの戻りで取得したいときなど、とても便利です。

ということで、とても手軽にキューが出来ました。

終わりに

似たような方法で、Advisory Locks(pg_try_advisory_lock)を使う方法がありますが、ORDER BYと組み合わせるとpg_try_advisory_lockを全行評価、すなわち全行ロックしてしまうので、優先度と組み合わせた取り出しが難しいです。

SKIP LOCKEDだと、そういった制約も回避できるので、9.5以降ならばSKIP LOCKEDを使った形のほうが簡単ではと思っています。

Redmine: 長いテキストをRedmine 3.4 のワイド表示とは異なる形で、広めに表示する(View customize plugin)

Redmine 3.4 では、長いテキストに対して「ワイド表示」というオプションが追加されていて、長いテキストを見やすく表示することが可能になりました。

上記機能だと、表示位置が説明欄の下に来てしまうので、今回は説明欄の上に表示するようにView customizeで実装してみました。

なお、Redmine 3.4の機能を使っているわけではないので、Redmine 3.4 未満でワイド表示したい場合にも参考になるかと思います。

View customize の設定内容

Path pattern

チケット画面を対象にします。

/issues

Code

Type:JavaScriptとして下記を設定します。カスタムフィールドのIDに応じて、customFieldId を変更してください。

$(function() {
  // 処理したいカスタムフィールドに応じて変更
  var customFieldId = 3;

  // 詳細表示エリア
  function changeDetailArea() {

    var target = $('div.cf_' + customFieldId + '.attribute');

    // 移動
    $('div.issue > div.attributes').append(
      $('<div>', { class: 'splitcontent' }).append(target));
  }

  // 編集エリア
  function changeEditArea() {

    var target = $('#issue_custom_field_values_' + customFieldId).parent();

    // 移動
    $('#attributes').append(target);
  }

  changeDetailArea();
  changeEditArea();

  // ステータス変更時などにDOMが差し替えられるので
  // フォームの内容が書き変わるたびに調整
  var _replaceIssueFormWith = replaceIssueFormWith;
  replaceIssueFormWith = function(html){

    _replaceIssueFormWith(html);

    changeDetailArea();
    changeEditArea();
  };
})

画面イメージ

「長いテキスト1」が、View customizeで変更したものになります。

f:id:onozaty:20170809004349p:plain

Jackson 2.8.7 で、Lombokの@AllArgsConstructorと@NoArgsConstructorを指定していて、一部プロパティを@JsonIgnoreとしているとJsonMappingExceptionが起きる

下記のようなコードを書くと、Jackson 2.8.7 だとエラーとなります。

public class JacksonExample {

  public static void main(String[] args) throws IOException {

    ObjectMapper mapper = new ObjectMapper();

    User userBefore = new User(1, "Taro", "password");

    String json = mapper.writeValueAsString(userBefore);
    User userAfter = mapper.readValue(json, User.class);
  }

  @Data
  @AllArgsConstructor
  @NoArgsConstructor
  public static class User {

    private int id;

    private String name;

    @JsonIgnore
    private String password;
  }
}

スタックとレースは下記のような感じです。

Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Could not find creator property with name 'id' (in class com.example.JacksonExample$User)
 at [Source: {"id":1,"name":"Taro"}; line: 1, column: 1]
    at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:270)
    at com.fasterxml.jackson.databind.DeserializationContext.reportMappingException(DeserializationContext.java:1234)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerFactory.addBeanProps(BeanDeserializerFactory.java:551)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerFactory.buildBeanDeserializer(BeanDeserializerFactory.java:226)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerFactory.createBeanDeserializer(BeanDeserializerFactory.java:141)
    at com.fasterxml.jackson.databind.deser.DeserializerCache._createDeserializer2(DeserializerCache.java:403)
    at com.fasterxml.jackson.databind.deser.DeserializerCache._createDeserializer(DeserializerCache.java:349)
    at com.fasterxml.jackson.databind.deser.DeserializerCache._createAndCache2(DeserializerCache.java:264)
    at com.fasterxml.jackson.databind.deser.DeserializerCache._createAndCacheValueDeserializer(DeserializerCache.java:244)
    at com.fasterxml.jackson.databind.deser.DeserializerCache.findValueDeserializer(DeserializerCache.java:142)
    at com.fasterxml.jackson.databind.DeserializationContext.findRootValueDeserializer(DeserializationContext.java:476)
    at com.fasterxml.jackson.databind.ObjectMapper._findRootDeserializer(ObjectMapper.java:3899)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3794)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2842)
    at com.example.JacksonExample.main(JacksonExample.java:32)

エラー見ても意味がわからなくて、調べていったら、結局 2.8.7 だけでの問題で、前後のバージョン(2.8.6、2.8.8)で発生しないものでした。

条件としては、下記の場合に起きるようです。

  • 引数なしコンストラクタと、引数ありコンストラクタの両方がある
  • 引数ありコンストラクタの引数の数と、JSON上のプロパティの数が異なる

なお 2.8.7 で回避したい場合には、Jacksonとしてどちらかのコンストラクタしか使わないようにさせれば大丈夫でした。ということで、コンストラクタにJsonIgnoreを指定すれば回避できます。

  @Data
  @AllArgsConstructor(onConstructor = @__(@JsonIgnore))
  @NoArgsConstructor
  public static class User {

    private int id;

    private String name;

    @JsonIgnore
    private String password;
  }

ちなみに、Jacksonのようなものって、引数なしコンストラクタしか使わないと思っていましたが、そうではないんですね。 引数ありコンストラクタの引数とプロパティ名をどうマッピングしているんだろうと思いましたが、@java.beans.ConstructorProperties を見てマッピングしてくれているようでした。(LombokだとConstructorPropertiesも付与される)

Jacksonだと Immutable なオブジェクトが扱えるのですばらしいですね。