An Unsuccessful Experiment with Nemotron

Michael Lynch

Nemotronで失敗した実験

原文は Michael Lynch により に公開されました。 このブログを購読する

数週間前、NVIDIAは大規模言語モデル「Nemotron」を公開した。MetaのLlama 3.1 70Bをベースにして派生させたモデルだ。

NVIDIAはリリース時に、Nemotronが特定のベンチマークでGPT-4oやClaude 3.5 Sonnetを上回ったと主張していた。ワクワクするニュースだった。というのも、これまでの経験では、セルフホスト可能なAIモデルは精度や品質の面で商用モデルより約1年遅れているという印象があったからだ。

そこで、Claude 3.5 Sonnetのような商用モデルと比べてどうなのかを確かめるため、簡単なコーディングタスクでNemotronを試してみることにした。

GPU搭載のクラウドサーバーを用意する

最初は手元のワークステーションでNemotronを動かそうとしたが、9年前のGTX 970は「冗談だろ、ハハ!」と言わんばかりだった。OllamaはNemotronモデルのインストールすら拒否した。

そこで、Scalewayで以下のスペックのサーバーを用意した。

  • サーバーインスタンスタイプ: H100-1-80G
  • OS: Debian 12
  • ディスクサイズ: 200 GB(モデルが大きいため必要)

SSHで接続するために、サーバーのlocalhostインターフェースで動作するWebインターフェースにアクセスする必要があるため、ポートフォワーディングを指定して以下のコマンドを実行した。

TARGET_IP='51.159.150.3' # Change to your server's IP.
REMOTE_PORT='8080'
LOCAL_PORT='8080'

# SSH in and port-forward a port to access the Open-WebUI web interface.
ssh "${TARGET_IP}" -L "${REMOTE_PORT}:localhost:${LOCAL_PORT}"

Dockerをインストールする

次に、Open WebUIのWebインターフェース上でOllamaを動かせるようにDockerをインストールした。

sudo apt-get update && \
   sudo apt-get install ca-certificates curl && \
   sudo install -m 0755 -d /etc/apt/keyrings && \
  sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
    -o /etc/apt/keyrings/docker.asc && \
  sudo chmod a+r /etc/apt/keyrings/docker.asc &&\
  echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null && \
  sudo apt-get update && \
  sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
  sudo usermod -aG docker "${USER}" && \
  newgrp docker

すべて正常に動作しているか確認するには、以下のコマンドを実行する。

docker run hello-world

OllamaとOpen WebUIを起動する

前回のOllama実験以来、インストール手順はさらに簡単になっている。

今ではOllamaとOpen WebUIを単一のDockerコンテナでインストールできるため、Docker Composeを扱うよりも手軽だ。

docker run \
  -d \
  -p 8080:8080 \
  --gpus=all \
  -v ollama:/root/.ollama \
  -v open-webui:/app/backend/data \
  --name open-webui \
  --restart always \
  ghcr.io/open-webui/open-webui:ollama

サーバーが起動したら、ブラウザで以下のURLにアクセスする。

最初にログインを促すページが表示される。「Sign up」をクリックする。

あとは適当な情報を入力すればよい。少なくとも私が試した限りでは、有効なメールアドレスである必要はないようだ。

ここから使用するモデルをダウンロードする必要がある。設定ボタンをクリックする。

Pull a model from Ollama.comと書かれている部分をクリックする。

モデル名にnemotronと入力し、ダウンロードボタンを押す。

ファイルサイズが大きいため、ダウンロードには数分かかる。ダウンロードの進行状況がしばらく100%のまま止まるが、モデルが完全にダウンロードされたことを知らせるポップアップが表示されるまでは完了していない。

テスト1: sql.Namedを使ったコードのリファクタリング

最近、Goの標準ライブラリにあるsql.Named関数を知った。従来は次のように?プレースホルダーを使ってSQLクエリを書いていた。

db.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(?,?,?,?)`, // Ugly placeholders!
        id.String(),
        formatTime(r.Time),
        r.ClientIP,
        r.UserAgent,
    )

これは見栄えが悪い。読み手はそれぞれの?が何を表しているかを頭の中で覚えておかなければならず、クエリを書き換える際に間違いやすいからだ。

代わりに、次のように名前付きパラメータを使えば、より読みやすいクエリを書ける。

db.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(@entry_id, @download_timestamp, @client_ip, @user_agent)`,
        sql.Named("entry_id", id.String()),
        sql.Named("download_timestamp", formatTime(r.Time)),
        sql.Named("client_ip", r.ClientIP),
        sql.Named("user_agent", r.UserAgent),
    )

これはLLMに任せるコーディングタスクの中でもお気に入りの一つだ。LLMにとっては説明しやすい一方で、人間が手作業で直すには面倒な作業だからだ。

Nemotronを試すために、Picoshareで以前自分が行った?プレースホルダーをsql.Namedに置き換えるリファクタリングをもう一度やらせてみた。

プロンプト

このコードをリファクタリングして、SQLクエリ内の?プレースホルダーをsql.Named引数に置き換えてください:

package sqlite
import (
    "database/sql"
    "log"
    "github.com/mtlynch/picoshare/v2/picoshare"
)
func (d DB) InsertEntryDownload(id picoshare.EntryID, r picoshare.DownloadRecord) error {
    log.Printf("recording download of file ID %s from client %s", id.String(), r.ClientIP)
    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(?,?,?,?)`,
        id.String(),
        formatTime(r.Time),
        r.ClientIP,
        r.UserAgent,
    ); err != nil {
        log.Printf("insert into downloads table failed: %v", err)
        return err
    }
    return nil
}

func (d DB) GetEntryDownloads(id picoshare.EntryID) ([]picoshare.DownloadRecord, error) {
    rows, err := d.ctx.Query(`
    SELECT
        download_timestamp,
        client_ip,
        user_agent
    FROM
        downloads
    WHERE
        entry_id=?
    ORDER BY
        download_timestamp DESC`, id)
    if err == sql.ErrNoRows {
        return []picoshare.DownloadRecord{}, nil
    } else if err != nil {
        return []picoshare.DownloadRecord{}, err
    }
    downloads := []picoshare.DownloadRecord{}
    for rows.Next() {
        var downloadTimeRaw string
        var clientIP string
        var userAgent string
        if err := rows.Scan(&downloadTimeRaw, &clientIP, &userAgent); err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        dt, err := parseDatetime(downloadTimeRaw)
        if err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        downloads = append(downloads, picoshare.DownloadRecord{
            Time:      dt,
            ClientIP:  clientIP,
            UserAgent: userAgent,
        })
    }
    return downloads, nil
}

出力

package sqlite

import (
    "database/sql"
    "log"
    "github.com/mtlynch/picoshare/v2/picoshare"
)

func (d DB) InsertEntryDownload(id picoshare.EntryID, r picoshare.DownloadRecord) error {
    log.Printf("recording download of file ID %s from client %s", id.String(), r.ClientIP)
    params := []sql.NamedArg{
        {Name: "entry_id", Value: id.String()},
        {Name: "download_timestamp", Value: formatTime(r.Time)},
        {Name: "client_ip", Value: r.ClientIP},
        {Name: "user_agent", Value: r.UserAgent},
    }
    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(:entry_id, :download_timestamp, :client_ip, :user_agent)`,
        params...,
    ); err != nil {
        log.Printf("insert into downloads table failed: %v", err)
        return err
    }
    return nil
}

func (d DB) GetEntryDownloads(id picoshare.EntryID) ([]picoshare.DownloadRecord, error) {
    params := []sql.NamedArg{
        {Name: "entry_id", Value: id},
    }
    rows, err := d.ctx.Query(`
    SELECT
        download_timestamp,
        client_ip,
        user_agent
    FROM
        downloads
    WHERE
        entry_id=:entry_id
    ORDER BY
        download_timestamp DESC`,
        params...,
    )
    if err == sql.ErrNoRows {
        return []picoshare.DownloadRecord{}, nil
    } else if err != nil {
        return []picoshare.DownloadRecord{}, err
    }
    downloads := []picoshare.DownloadRecord{}
    for rows.Next() {
        var downloadTimeRaw string
        var clientIP string
        var userAgent string
        if err := rows.Scan(&downloadTimeRaw, &clientIP, &userAgent); err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        dt, err := parseDatetime(downloadTimeRaw)
        if err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        downloads = append(downloads, picoshare.DownloadRecord{
            Time:      dt,
            ClientIP:  clientIP,
            UserAgent: userAgent,
        })
    }
    return downloads, nil
}

考察

というわけで、Nemotronは幸先の悪いスタートとなった。このコードはコンパイルできない。sqlパッケージのAPIはsql.NamedArgをパラメータとして受け付けないからだ。

store/sqlite/downloads.go:28:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Exec
store/sqlite/downloads.go:51:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Query

仮に受け付けたとしても、同じプロンプトに対するClaude 3.5 Sonnetの出力と比べると可読性は劣る。Claudeは不要な配列を宣言するのではなく、sql.Named引数をインラインでそのまま生成するため、はるかに理解しやすい。

// Excerpt of output from claude-3-5-sonnet-20241022

    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(@entryID, @timestamp, @clientIP, @userAgent)`,
        sql.Named("entryID", id.String()),
        sql.Named("timestamp", formatTime(r.Time)),
        sql.Named("clientIP", r.ClientIP),
        sql.Named("userAgent", r.UserAgent),
    ); err != nil {
//      ...

とはいえ、Nemotronはこちらの意図自体は理解しており、実装方法についてもおおむね正しい方向性は掴んでいた。

テスト2: Goの関数に対するユニットテストの作成

2つ目のテストでは、PicoShare内の関数に対するユニットテストをNemotronに書かせてみた。

プロンプト

このコードに対するユニットテストを作成してください:

package parse

import (
    "errors"
    "strings"

    "github.com/mtlynch/picoshare/v2/picoshare"
)

// MaxFilenameBytes is the maximum number of bytes allowed for uploaded files
// There's no technical reason on PicoShare's side for this limitation, but it's
// useful to have some upper bound to limit malicious inputs, and 255 is a
// common filename limit (in single-byte characters) across most filesystems.
const MaxFilenameBytes = 255

var ErrFilenameEmpty = errors.New("filename must be non-empty")
var ErrFilenameTooLong = errors.New("filename too long")
var ErrFilenameHasDotPrefix = errors.New("filename cannot begin with dots")
var ErrFilenameIllegalCharacters = errors.New("illegal characters in filename")

func Filename(s string) (picoshare.Filename, error) {
    if s == "" {
        return picoshare.Filename(""), ErrFilenameEmpty
    }
    if len(s) > MaxFilenameBytes {
        return picoshare.Filename(""), ErrFilenameTooLong
    }
    if s == "." || strings.HasPrefix(s, "..") {
        return picoshare.Filename(""), ErrFilenameHasDotPrefix
    }
    if strings.ContainsAny(s, "\\/\a\b\t\n\v\f\r\n") {
        return picoshare.Filename(""), ErrFilenameIllegalCharacters
    }
    return picoshare.Filename(s), nil
}

スタイルは以下のファイルに合わせてください:

package parse_test

import (
    "fmt"
    "strings"
    "testing"

    "github.com/mtlynch/picoshare/v2/handlers/parse"
    "github.com/mtlynch/picoshare/v2/picoshare"
)

func TestGuestLinkLabel(t *testing.T) {
    for _, tt := range []struct {
        description string
        input       string
        output      picoshare.GuestLinkLabel
        err         error
    }{
        {
            description: "accept valid label",
            input:       "For my good pals",
            output:      picoshare.GuestLinkLabel("For my good pals"),
            err:         nil,
        },
        {
            description: "allow empty label",
            input:       "",
            output:      picoshare.GuestLinkLabel(""),
            err:         nil,
        },
        {
            description: "reject labels that are too long",
            input:       strings.Repeat("A", parse.MaxGuestLinkLabelLength+1),
            output:      picoshare.GuestLinkLabel(""),
            err:         parse.ErrGuestLinkLabelTooLong,
        },
    } {
        t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.input), func(t *testing.T) {
            label, err := parse.GuestLinkLabel(tt.input)
            if got, want := err, tt.err; got != want {
                t.Fatalf("err=%v, want=%v", err, want)
            }
            if got, want := label, tt.output; got != want {
                t.Errorf("label=%v, want=%v", label, want)
            }
        })
    }
}

出力

package parse_test
import (
    "fmt"
    "strings"
    "testing"
    "github.com/mtlynch/picoshare/v2/picoshare"
    "github.com/mtlynch/picoshare/v2/parse"
)

func TestFilename(t *testing.T) {
    for _, tt := range []struct {
        description string
        input       string
        output      picoshare.Filename
        err         error
    }{
        {
            description: "accept valid filename",
            input:       "example.txt",
            output:      picoshare.Filename("example.txt"),
            err:         nil,
        },
        {
        (description: "reject empty filename",
            input:       "",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameEmpty,
        },
        {
            description: "reject filename too long",
            input:       strings.Repeat("A", parse.MaxFilenameBytes+1),
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameTooLong,
        },
        {
            description: "reject filename with dot prefix (single dot)",
            input:       ".hidden",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameHasDotPrefix,
        },
        {
            description: "reject filename with dot prefix (double dots)",
            input:       "..parentdir",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameHasDotPrefix,
        },
        {
        (description: "reject filename with illegal characters (slash)",
            input:       "example/file.txt",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameIllegalCharacters,
        },
        {
            description: "reject filename with illegal characters (backspace)",
            input:       "example\x08.txt", // \x08 represents a backspace character
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameIllegalCharacters,
        },
    } {
        t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.input), func(t *testing.T) {
            filename, err := parse.Filename(tt.input)
            if got, want := err, tt.err; got != want {
                t.Fatalf("err=%v, want=%v", err, want)
            }
            if got, want := filename, tt.output; got != want {
                t.Errorf("filename=%v, want=%v", filename, want)
            }
        })
    }
}

考察

Nemotronの出来はここではまずまずだったが、明らかなミスもあった。

  • Nemotronはparseパッケージのインポートパスを間違えた。
    • サンプルのテストから、parseパッケージのパスが.../parseではなく.../handlers/parseであることを推測すべきだった。
  • descriptionフィールドの前のタブ文字の代わりに、なぜか(を出力してしまい、目立つ構文エラーを引き起こした。
  • 先頭がドットであるファイル名に関するルールを誤解していた。
    • .hiddenのような先頭がドットのファイル名は拒否されるべきだというテストを作成したが、実際の実装では単一のドットで始まるファイル名は許容されている。

それ以外では、動作をまずまずカバーする有効なテストを作成していた。claude-3-5-sonnet-20241022と比較してみたところ、Claudeも似たようなテストを作成していたが、Nemotronのようなエラーはなかった。

まとめ

今回試した数少ないコーディングタスクに関して、Nemotronにはがっかりさせられた。これらは簡単から中程度の難易度だと考えているので、失敗したのは残念だ。

どちらのケースでも、Claude 3.5 Sonnetからの出力はNemotronの出力を明確に上回っていた。

それでも、セルフホスト可能なLLMの分野で動きがあるのは常にワクワクすることだ。今後も商用モデルに追いついていってほしいと思う。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント