Ctrl K
ComunidadGuías…

scripts/bonsai-comparison.rb

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
require 'json'
require 'net/http'
require 'open3'
require 'digest'
require 'fileutils'
require 'timeout'
require 'cgi'

def percentile(values, fraction)
  raise 'empty measurements' if values.empty?
  values.sort[(fraction * values.length).ceil - 1]
end

def score_answer(text, expected)
  text.strip == expected ? 1 : 0
end

if ARGV == ['--self-test']
  raise 'median' unless percentile([4, 1, 3, 2], 0.5) == 2
  raise 'p95' unless percentile((1..30).to_a, 0.95) == 29
  raise 'correct answer' unless score_answer(" 42\n", '42') == 1
  raise 'extra explanation accepted' unless score_answer('42 because...', '42') == 0
  puts 'PASS: percentile and strict task scoring checks'
  exit
end

root, bonsai_path, qwen_path = ARGV
abort 'Usage: ruby scripts/bonsai-comparison.rb OUTPUT_DIR BONSAI_GGUF QWEN_GGUF' unless ARGV.length == 3
FileUtils.mkdir_p(root, mode: 0700)
models = {
  'bonsai' => {name: 'Bonsai 8B Q1_0', path: bonsai_path, sha256: '284a335aa3fb2ced3b1b01fcb40b08aa783e3b70832767f0dd2e3fdfa134bd54'},
  'qwen' => {name: 'Qwen3 8B Q4_K_M', path: qwen_path, sha256: 'd98cdcbd03e17ce47681435b5150e34c1417f50b5c0019dd560e4882c5745785'}
}
models.each_value do |model|
  abort "Digest mismatch: #{model[:name]}" unless Digest::SHA256.file(model[:path]).hexdigest == model[:sha256]
  model[:bytes] = File.size(model[:path])
end
port = 48343
server = '/opt/homebrew/opt/llama.cpp/bin/llama-server'
origin = "http://127.0.0.1:#{port}"

def request_json(origin, path, payload = nil)
  uri = URI(origin + path)
  request = payload ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  request['Content-Type'] = 'application/json'
  request.body = JSON.generate(payload) if payload
  response = Net::HTTP.start(uri.host, uri.port, nil, open_timeout: 3, read_timeout: 120) { |http| http.request(request) }
  raise "HTTP #{response.code} at #{path}" unless response.code == '200'
  raise 'oversized JSON response' if response.body.bytesize > 4 * 1024 * 1024
  JSON.parse(response.body)
end

def completion(origin, tokens, maximum, fixed_length)
  uri = URI(origin + '/completion')
  request = Net::HTTP::Post.new(uri)
  request['Content-Type'] = 'application/json'
  request.body = JSON.generate(prompt: tokens, n_predict: maximum, stream: true,
    temperature: 0, seed: 42, cache_prompt: false, ignore_eos: fixed_length)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  first_token = nil
  content = ''
  timings = nil
  pending = ''
  Net::HTTP.start(uri.host, uri.port, nil, open_timeout: 3, read_timeout: 120) do |http|
    http.request(request) do |response|
      raise "completion HTTP #{response.code}" unless response.code == '200'
      response.read_body do |chunk|
        pending << chunk
        raise 'oversized stream' if pending.bytesize + content.bytesize > 1024 * 1024
        while (boundary = pending.index("\n\n"))
          event = pending.slice!(0, boundary + 2)
          event.lines.grep(/^data: /).each do |line|
            data = line.delete_prefix('data: ').strip
            next if data == '[DONE]'
            parsed = JSON.parse(data)
            raise 'inference error' if parsed['error']
            piece = parsed.fetch('content', '')
            first_token ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) unless piece.empty?
            content << piece
            timings = parsed['timings'] if parsed['timings']
          end
        end
      end
    end
  end
  raise 'missing completion timings' unless timings && first_token
  {ttft_seconds: first_token - started, wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
   decode_tps: timings.fetch('predicted_per_second'), prompt_tps: timings.fetch('prompt_per_second'),
   output_tokens: timings.fetch('predicted_n'), prompt_tokens: timings.fetch('prompt_n'),
   cached_tokens: timings.fetch('cache_n'), timings: timings, content: content}
end

def prompt_tokens(origin, text, target = nil)
  messages = [{role: 'system', content: 'Follow the user instructions precisely. Be concise.'}, {role: 'user', content: text}]
  template = request_json(origin, '/apply-template', {messages: messages, chat_template_kwargs: {enable_thinking: false}}).fetch('prompt')
  tokens = request_json(origin, '/tokenize', {content: template, add_special: true}).fetch('tokens')
  return tokens unless target
  raise 'base prompt exceeds target' if tokens.length >= target
  padding = request_json(origin, '/tokenize', {content: "Reference material: ordinary archival notes about weather and geography.\n", add_special: false}).fetch('tokens')
  (padding * ((target - tokens.length) / padding.length + 1)).first(target - tokens.length) + tokens
end

quality_tasks = [
  ['math', 'What is 17 multiplied by 24? Reply with only the integer.', '408'],
  ['math', 'A price of 80 is discounted by 15 percent. Reply with only the final price as an integer.', '68'],
  ['math', 'What is the next integer: 2, 6, 12, 20, 30? Reply with only the integer.', '42'],
  ['math', 'Three identical machines make 18 parts in two hours. How many parts do five machines make in four hours? Reply with only the integer.', '60'],
  ['logic', 'All dax are wugs. No wugs are zibs. Can any dax be a zib? Reply only yes or no.', 'no'],
  ['logic', 'Some painters are swimmers. All swimmers are runners. Must some painters be runners? Reply only yes or no.', 'yes'],
  ['logic', 'Alice is taller than Bob. Bob is taller than Chen. Who is shortest? Reply with only the name.', 'Chen'],
  ['logic', 'If it rains the road is wet. The road is wet. Does this prove it rained? Reply only yes or no.', 'no'],
  ['code', 'In Rust, what integer does (0..5).filter(|number| number % 2 == 0).sum::<i32>() produce? Reply only with the integer.', '6'],
  ['code', 'In Rust, what is the value of -7 % 3? Reply only with the integer.', '-1'],
  ['code', 'A Python list starts as [1,2,3]. After values.append(values.pop(0)), what is values? Reply exactly in compact JSON array format.', '[2,3,1]'],
  ['code', 'In Rust, let mut total = 0; for number in 1..4 { total += number; } What is total? Reply only with the integer.', '6'],
  ['instructions', 'Return the word kiwi exactly three times separated by commas with no spaces or other text.', 'kiwi,kiwi,kiwi'],
  ['instructions', 'Sort these integers in ascending order: 9, -2, 4, 0. Reply as a compact JSON array, without spaces.', '[-2,0,4,9]'],
  ['instructions', 'Output only a JSON object with key ok and boolean value true, without spaces.', '{"ok":true}'],
  ['instructions', 'Reply with exactly the lowercase word confirmed, and nothing else.', 'confirmed'],
  ['grounding', 'Record: Project Cedar launches on May 8. Project Maple launches on June 2. Which project launches on June 2? Reply only the project name without the word Project.', 'Maple'],
  ['grounding', 'Record: Invoice A totals 90; Invoice B totals 120; Invoice C totals 75. Which invoice has the largest total? Reply only A, B, or C.', 'B'],
  ['grounding', 'Record: Mira owns the blue folder. Jo owns the red folder. Who owns the green folder? Reply exactly unknown if not stated.', 'unknown'],
  ['grounding', 'Record: Ticket T1 is closed. Ticket T2 is open. Ticket T3 is closed. Return only the open ticket ID.', 'T2']
]
workloads = {
  'short-chat' => ['Explain why leaves change color in autumn in a concise paragraph.', nil],
  'summarization' => ["Summarize these meeting notes in three bullet points: The release is delayed by two days. Mira owns testing. Jo owns deployment. The critical issue is fixed. Documentation review remains open. The next check-in is Friday.\n" * 12, nil],
  'coding' => ['Write a Rust function that returns the largest value in a slice of i32 as Option<i32>, and explain how it handles an empty slice.', nil],
  'context-4k' => ['Explain the distinction between weather and climate in a short paragraph.', 4096],
  'context-16k' => ['Explain the distinction between weather and climate in a short paragraph.', 16384]
}
report = {schemaVersion: 1, title: 'Bonsai vs Qwen3: measured speed and task accuracy',
  hardware: 'Apple M4 Max, 14 CPU cores, 32 GPU cores, 36 GB RAM, macOS 26.5',
  runtime: 'llama.cpp b10090 (7347430f4), Metal',
  settings: {context: 17408, slots: 1, gpu_layers: 99, threads: 10, flash_attention: 'off', kv: 'f16', thinking: false,
    speed_output_tokens: 128, quality_output_limit: 256, temperature: 0, seed: 42, cache_prompt: false,
    repetitions_per_workload: 30, order: %w[bonsai qwen qwen bonsai bonsai qwen]},
  caveats: ['Local illustrative tasks, not general intelligence or a published benchmark.',
    'Exact-answer scoring includes instruction-format compliance; raw answers are retained.',
    'Quality uses 20 unique tasks once per model; repeated speed samples do not enlarge the quality sample.',
    'Long-context speed uses token padding, not a long-context understanding evaluation.',
    'Fixed-length speed requests ignore EOS; quality requests allow natural completion.',
    'Desktop remains in normal use; thermal state and GPU power are unmeasured.',
    'RSS excludes some Metal allocations. No FP16, energy, or other-hardware claims are verified.'],
  models: models, samples: [], quality: [], errors: [], runs: []}
persist = -> { File.write(File.join(root, 'results.json'), JSON.pretty_generate(report)) }
persist.call
raise 'benchmark port already occupied' if system('/usr/sbin/lsof', '-nP', "-iTCP:#{port}", '-sTCP:LISTEN', out: File::NULL)
%w[bonsai qwen qwen bonsai bonsai qwen].each_with_index do |model_id, block|
  model = models.fetch(model_id)
  log = File.open(File.join(root, "server-#{block}-#{model_id}.log"), 'w', 0600)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  pid = Process.spawn(server, '-m', model[:path], '--alias', model_id, '--host', '127.0.0.1', '--port', port.to_s,
    '-c', '17408', '-np', '1', '-t', '10', '-tb', '10', '-ngl', '99', '-fa', 'off', '-ctk', 'f16', '-ctv', 'f16',
    '--reasoning', 'off', '--chat-template-kwargs', '{"enable_thinking":false}', out: log, err: log)
  begin
    Timeout.timeout(120) do
      loop do
        raise 'server exited before readiness' if Process.waitpid(pid, Process::WNOHANG)
        begin
          break if request_json(origin, '/health')['status'] == 'ok'
        rescue Errno::ECONNREFUSED, RuntimeError
          IO.select(nil, nil, nil, 0.2)
        end
      end
    end
    identity = request_json(origin, '/v1/models')
    raise 'wrong model identity' unless identity.fetch('data').any? { |entry| entry['id'] == model_id }
    listener, status = Open3.capture2('/usr/sbin/lsof', '-nP', '-a', '-p', pid.to_s, "-iTCP:#{port}", '-sTCP:LISTEN', '-Fn')
    raise 'listener identity mismatch' unless status.success? && listener.include?("n127.0.0.1:#{port}")
    report[:runs] << {model: model_id, block: block, startup_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
      props: request_json(origin, '/props').slice('model_path', 'total_slots', 'default_generation_settings')}
    prepared = workloads.transform_values { |text, target| prompt_tokens(origin, text, target) }
    prepared.each { |_, tokens| completion(origin, tokens, 128, true) }
    10.times do |iteration|
      prepared.to_a.rotate(iteration % prepared.length).each do |name, tokens|
        sample = completion(origin, tokens, 128, true)
        raise 'unexpected output length' unless sample[:output_tokens] == 128
        raise 'prompt cache invalidates trial' unless sample[:cached_tokens] == 0
        rss, = Open3.capture2('/bin/ps', '-p', pid.to_s, '-o', 'rss=')
        report[:samples] << sample.merge(model: model_id, workload: name, block: block, iteration: iteration, rss_kib: Integer(rss.strip))
        persist.call
      end
      puts "Block #{block + 1}/6 #{model_id}: #{iteration + 1}/10 workload sets completed"
      STDOUT.flush
    end
    unless report[:quality].any? { |entry| entry[:model] == model_id }
      quality_tasks.each_with_index do |(category, prompt, expected), index|
        sample = completion(origin, prompt_tokens(origin, prompt), 256, false)
        report[:quality] << sample.merge(model: model_id, task: index + 1, category: category, prompt: prompt, expected: expected,
          passed: score_answer(sample[:content], expected) == 1, truncated: sample[:output_tokens] >= 256)
        persist.call
      end
    end
  rescue StandardError => error
    report[:errors] << {model: model_id, block: block, error: error.message}
    persist.call
    raise
  ensure
    begin
      Process.kill('TERM', pid)
      Timeout.timeout(15) { Process.waitpid(pid) }
    rescue Timeout::Error
      Process.kill('KILL', pid)
      Process.waitpid(pid)
    rescue Errno::ESRCH, Errno::ECHILD
    end
    log.close
  end
end
report[:summary] = models.keys.to_h do |model_id|
  rows = workloads.keys.to_h do |name|
    samples = report[:samples].select { |entry| entry[:model] == model_id && entry[:workload] == name }
    raise 'incomplete workload' unless samples.length == 30
    [name, {count: samples.length, prompt_tokens: samples.map { |entry| entry[:prompt_tokens] }.uniq,
      median_ttft: percentile(samples.map { |entry| entry[:ttft_seconds] }, 0.5),
      p95_ttft: percentile(samples.map { |entry| entry[:ttft_seconds] }, 0.95),
      median_decode_tps: percentile(samples.map { |entry| entry[:decode_tps] }, 0.5),
      median_wall: percentile(samples.map { |entry| entry[:wall_seconds] }, 0.5),
      p95_wall: percentile(samples.map { |entry| entry[:wall_seconds] }, 0.95),
      max_rss_gib: samples.map { |entry| entry[:rss_kib] / 1048576.0 }.max}]
  end
  quality = report[:quality].select { |entry| entry[:model] == model_id }
  [model_id, {workloads: rows, correct: quality.count { |entry| entry[:passed] }, total: quality.length}]
end
report[:completedAt] = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
persist.call
puts JSON.pretty_generate(report[:summary])