File size: 8,683 Bytes
0f6cfdd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
// Reference SMaLL-100 translator for Flutter (onnxruntime Dart package).
// This is the actual implementation shipped in a production S2S translator
// app (Android + iOS) — fully runnable and verified, not pseudo-code.
//
// pubspec.yaml:
//   dependencies:
//     onnxruntime: ^1.4.1
//
// Ship onnx/encoder_model.onnx, onnx/decoder_model_merged.onnx, tokenizer.json,
// lang_tokens.json as assets (or download them into app storage — sherpa/
// onnxruntime need real filesystem paths, not asset-bundle URIs). Implements
// the algorithm in ../USAGE.md: encode + prepend target-lang token -> encoder
// -> greedy merged-decoder loop with KV cache -> decode. Needs bpe_tokenizer.dart
// (same directory) to read tokenizer.json — Dart has no off-the-shelf
// HuggingFace-tokenizers binding, so this repo ships a small pure-Dart one.
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:onnxruntime/onnxruntime.dart';

import 'bpe_tokenizer.dart';

class Small100Translator {
  static const int _nLayers = 3;
  static const int _nHeads = 16;
  static const int _headDim = 64;
  static const int _decoderStart = 2;
  static const int _eos = 2;
  static const int _pad = 1;
  static const int _maxLen = 64;

  final BpeTokenizer _tok;
  final Map<String, int> _langToId;
  final OrtSession _encoder;
  final OrtSession _decoder;

  Small100Translator._(this._tok, this._langToId, this._encoder, this._decoder);

  static Future<Small100Translator> create({
    required String tokenizerJsonPath,
    required String langTokensPath,
    required String encoderPath,
    required String decoderPath,
  }) async {
    OrtEnv.instance.init();
    final tok = await BpeTokenizer.fromFile(tokenizerJsonPath);
    final lt = jsonDecode(await File(langTokensPath).readAsString()) as Map<String, dynamic>;
    final langToId = (lt['lang_to_id'] as Map).map((k, v) => MapEntry(k as String, (v as num).toInt()));
    final opts = OrtSessionOptions()..setIntraOpNumThreads(2);
    // On-device acceleration: unsupported ops silently fall back to CPU.
    // Some emulators (e.g. those that run on qemu but report a real device's
    // Build.FINGERPRINT) crash inside libonnxruntime.so with NNAPI enabled —
    // real devices are unaffected. If you hit that, detect the emulator and
    // skip appendNnapiProvider rather than disabling acceleration everywhere.
    if (Platform.isAndroid) {
      opts.appendNnapiProvider(NnapiFlags.useNone);
    } else if (Platform.isIOS) {
      opts.appendCoreMLProvider(CoreMLFlags.useNone);
    }
    final encoder = OrtSession.fromFile(File(encoderPath), opts);
    final decoder = OrtSession.fromFile(File(decoderPath), opts);
    return Small100Translator._(tok, langToId, encoder, decoder);
  }

  bool supportsLang(String code) => _langToId.containsKey(code);

  /// Translate [text] into [tgtLang] (e.g. 'en'). Returns '' for empty input or
  /// an unknown target language.
  Future<String> translate(String text, String tgtLang) async {
    if (text.trim().isEmpty) return '';
    final langId = _langToId[tgtLang];
    if (langId == null) return '';

    final runOpts = OrtRunOptions();
    // [tgt lang] + subwords + </s>  — SMaLL-100 puts the target-language token
    // on the *source* side, not via forced_bos_token_id.
    final ids = <int>[langId, ..._tok.encode(text), _eos];
    final seqLen = ids.length;

    final inputIds = OrtValueTensor.createTensorWithDataList(Int64List.fromList(ids), [1, seqLen]);
    final attnMask = OrtValueTensor.createTensorWithDataList(
        Int64List.fromList(List<int>.filled(seqLen, 1)), [1, seqLen]);

    final encOut = _encoder.run(runOpts, {'input_ids': inputIds, 'attention_mask': attnMask});
    final encoderHidden = encOut[0]!; // frozen across steps

    final past = <String, OrtValue>{};
    for (var i = 0; i < _nLayers; i++) {
      for (final kind in ['decoder', 'encoder']) {
        for (final kv in ['key', 'value']) {
          past['past_key_values.$i.$kind.$kv'] =
              OrtValueTensor.createTensorWithDataList(Float32List(0), [1, _nHeads, 0, _headDim]);
        }
      }
    }

    final outIds = <int>[];
    var cur = _decoderStart;
    var useCache = false;

    try {
      for (var step = 0; step < _maxLen; step++) {
        final stepInputIds = OrtValueTensor.createTensorWithDataList(Int64List.fromList([cur]), [1, 1]);
        final useCacheT = OrtValueTensor.createTensorWithDataList([useCache], [1]);

        final feeds = <String, OrtValue>{
          'encoder_attention_mask': attnMask,
          'input_ids': stepInputIds,
          'encoder_hidden_states': encoderHidden,
          'use_cache_branch': useCacheT,
          ...past,
        };

        final outputs = _decoder.run(runOpts, feeds);
        final outByName = <String, OrtValue?>{};
        for (var i = 0; i < _decoder.outputNames.length; i++) {
          outByName[_decoder.outputNames[i]] = outputs[i];
        }

        final next = _argmaxLastLogits(outByName['logits']!);
        stepInputIds.release();
        useCacheT.release();
        if (next == _eos) {
          _releaseOutputs(outputs, keep: {});
          break;
        }
        outIds.add(next);
        // Greedy decode can degenerate into a repeated token/short cycle on
        // out-of-distribution input; bail out instead of running to _maxLen.
        if (_degenerate(outIds)) {
          _releaseOutputs(outputs, keep: {});
          break;
        }

        final keep = <OrtValue>{};
        for (var i = 0; i < _nLayers; i++) {
          for (final kv in ['key', 'value']) {
            final decKey = 'past_key_values.$i.decoder.$kv';
            final newDec = outByName['present.$i.decoder.$kv']!;
            past[decKey]!.release();
            past[decKey] = newDec;
            keep.add(newDec);
            if (!useCache) {
              // Encoder KV is only produced on step 1 (use_cache_branch=false),
              // then frozen and reused for every later step.
              final encKey = 'past_key_values.$i.encoder.$kv';
              final newEnc = outByName['present.$i.encoder.$kv']!;
              past[encKey]!.release();
              past[encKey] = newEnc;
              keep.add(newEnc);
            }
          }
        }
        _releaseOutputs(outputs, keep: keep);

        cur = next;
        useCache = true;
      }
    } finally {
      for (final t in past.values) {
        t.release();
      }
      encoderHidden.release();
      inputIds.release();
      attnMask.release();
      runOpts.release();
    }

    return _tok.decode(outIds);
  }

  int _argmaxLastLogits(OrtValue logits) {
    final v = logits.value as List;
    final row = (v[0] as List).last as List; // [1, seq, vocab] -> last position
    var bestIdx = 0;
    var bestVal = double.negativeInfinity;
    for (var i = 0; i < row.length; i++) {
      if (i == _pad) continue; // never emit <pad>
      final x = (row[i] as num).toDouble();
      if (x > bestVal) {
        bestVal = x;
        bestIdx = i;
      }
    }
    return bestIdx;
  }

  // Bail out of greedy decoding if it degenerates into a repeated token or a
  // short repeating cycle (period 2-4) — cheap safety net beyond ../USAGE.md's
  // base algorithm, worth adopting on any platform.
  static bool _degenerate(List<int> ids) {
    final n = ids.length;
    if (n >= 6) {
      var same = true;
      for (var i = n - 6; i < n - 1; i++) {
        if (ids[i] != ids[i + 1]) {
          same = false;
          break;
        }
      }
      if (same) return true;
    }
    for (var p = 2; p <= 4; p++) {
      if (n >= p * 3) {
        var cyc = true;
        for (var i = 0; i < p * 2; i++) {
          if (ids[n - 1 - i] != ids[n - 1 - i - p]) {
            cyc = false;
            break;
          }
        }
        if (cyc) return true;
      }
    }
    return false;
  }

  void _releaseOutputs(List<OrtValue?> outputs, {required Set<OrtValue> keep}) {
    for (final o in outputs) {
      if (o != null && !keep.contains(o)) o.release();
    }
  }

  void dispose() {
    _encoder.release();
    _decoder.release();
  }
}

// Usage (e.g. from a Flutter widget's initState, after copying the asset
// files to real paths — see ModelManager-style staging in the app examples):
//
//   final t = await Small100Translator.create(
//     tokenizerJsonPath: '$modelsRoot/tokenizer.json',
//     langTokensPath: '$modelsRoot/lang_tokens.json',
//     encoderPath: '$modelsRoot/onnx/encoder_model.onnx',
//     decoderPath: '$modelsRoot/onnx/decoder_model_merged.onnx',
//   );
//   print(await t.translate('你好,请问最近的地铁站怎么走?', 'en'));
//   t.dispose();