Review · updated OCT 11

Qwen3 VL 30B A3B Instruct review: not one we'd recommend right now

It scored 46 out of 100, #32 of 56. It solved 13 of 30 coding jobs and scored 49 on reading documents. Runs on a 24 GB graphics card or a Mac with 32 GB.

The short version
  • Qwen3 VL 30B A3B Instruct is a free model from Alibaba's Qwen team that you can run on your own computer. In our tests it's not one we'd recommend right now: 46 out of 100, #32 of 56.
  • It solved 13 of 30 coding jobs and scored 49 on reading documents. On our hardest tasks it scored 33.
  • Runs on a 24 GB graphics card or a Mac with 32 GB.

Coding

Our coding test is 30 programming jobs, from small ones like reading time durations or cleaning up messy data to harder ones like a config-file parser or a double-entry ledger. We run each answer against tests the model never sees, and a job only counts if everything passes. Qwen3 VL 30B A3B Instruct got 13 of 30 right. The best local coders solved 29 of 30.

Reading documents

The second test hands the model things like an expense claim thread, a pay stub or an insurance statement, and asks for specific numbers and dates. Many questions need a bit of math, or noticing a correction further down the email. Qwen3 VL 30B A3B Instruct scored 49; the best model scored 100.

TestScorePublic questionsSecret questions
Coding434344
Reading documents496644
Decisions757176

On the 18 hardest tasks (included in the scores above) it scored 33. This number separates the top models.

This model did much better on our public questions than on the secret ones. Sometimes that's luck; it can also mean the model has seen the public questions before.

We tested the full-size version online. The smaller versions you download usually score a little lower.

Can your computer run it?

You can download Qwen3 VL 30B A3B Instruct in several sizes. Smaller versions fit on more computers and lose a little quality, so pick the biggest one that fits.

Your computerRuns it?Version to download
Graphics card, 8 GBNo–
Graphics card, 12 GBNo–
Graphics card, 16 GBNo–
Graphics card, 24 GBYesQ5
Graphics card, 32 GBYesQ6
Mac, 16 GBNo–
Mac, 24 GBNo–
Mac, 32 GBYesQ4
Mac, 48 GBYesQ8
Mac, 64 GBYesQ8
Mac, 96 GBYesQ8
Mac, 128 GBYesQ8

For normal-length chats. Long documents need more memory. Q4 or Q8? · How we worked this out

How to try it

Search for "Qwen3 VL 30B A3B Instruct GGUF" in LM Studio or on Hugging Face and pick the version that fits your computer. New to this? Start with our Ollama guide.

Want the strongest all-rounder instead? Qwen3.6 27B scored 97.

Its actual answers

What Qwen3 VL 30B A3B Instruct wrote for our public questions, word for word.

Coding: INI parser (hard) Didn't work
```python
import re
from typing import Dict, List, Tuple

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    lines = text.splitlines()
    result: Dict[str, Dict[str, str]] = {}
    current_section: str = None
    default_section: Dict[str, str] = {}
    i = 0

    # Helper to strip and check if line is empty or comment
    def is_comment_or_empty(line: str) -> bool:
        stripped = line.strip()
        return len(stripped) == 0 or stripped[0] in (';', '#')

    # Helper to parse a key-value line
    def parse_key_value(line: str, section: str) -> Tuple[str, str]:
        # Strip leading/trailing whitespace
        line = line.strip()
        if not line:
            raise ValueError("Malformed line: empty after stripping")

        # Check for inline comment
        if ' ;' in line or ' #' in line:
            # Find first occurrence of ' ;' or ' #' after any leading whitespace
            # But only if it's not inside double quotes
            in_quotes = False
            comment_start = -1
            for j, char in enumerate(line):
                if char == '"':
                    in_quotes = not in_quotes
                elif not in_quotes and (char == ';' or char == '#'):
                    if j > 0 and line[j-1] == ' ':
                        comment_start = j
                        break

            if comment_start != -1:
                line = line[:comment_start].rstrip()

        # Check if line is empty after removing comment
        if not line:
            raise ValueError("Malformed line: empty after removing comment")

        # Split on first = or :, whichever comes first
        if '=' in line and ':' in line:
            split_pos = min(line.find('='), line.find(':'))
        elif '=' in line:
            split_pos = line.find('=')
        elif ':' in line:
            split_pos = line.find(':')
        else:
            raise ValueError(f"Malformed line: no '=' or ':' found in '{line}'")

        key_part = line[:split_pos].strip()
        value_part = line[split_pos + 1:].strip()

        # Check if value is quoted
        if value_part.startswith('"') and value_part.endswith('"'):
            value = value_part[1:-1]  # Remove quotes, keep everything inside
        else:
            value = value_part

        # Key is stripped and lowercased
        key = key_part.lower()

        # Validate key
        if not key:
            raise ValueError(f"Malformed line: empty key in '{line}'")

        return key, value

    # Helper to interpolate values
    def interpolate_value(value: str, section: str, all_sections: Dict[str, Dict[str, str]]) -> str:
        # Find all ${...} patterns
        pattern = r'\$\{([^}]+)\}'
        matches = re.findall(pattern, value)
        if not matches:
            return value

        # Process each match
        for match in matches:
            if ':' in match:
                # ${section:key}
                section_name, key_name = match.split(':', 1)
                if section_name not in all_sections:
                    raise KeyError(f"Section '{section_name}' not found")
                if key_name not in all_sections[section_name]:
                    raise KeyError(f"Key '{key_name}' not found in section '{section_name}'")
                replacement = all_sections[section_name][key_name]
            else:
                # ${key}
                # Check current section first, then DEFAULT
                if section not in all_sections:
                    raise KeyError(f"Section '{section}' not found")
                if match in all_sections[section]:
                    replacement = all_sections[section][match]
                elif match in default_section:
                    replacement = default_section[match]
                else:
                    raise KeyError(f"Key '{match}' not found in section '{section}' or DEFAULT")

            # Replace with interpolated value
            value = value.replace(f'${{{match}}}', replacement)

            # Check for cycles
            if '${' in value:
                # Recursive interpolation
                return interpolate_value(value, section, all_sections)

        return value

    # Helper to handle continuation lines
    def handle_continuation(lines: List[str], i: int, section: str, key: str, current_value: str) -> Tuple[int, str]:
        while i < len(lines):
            line = lines[i]
            i += 1
            stripped = line.strip()
            if not stripped:
                continue
            if stripped[0] in (';', '#'):
                continue
            if ' ;' in stripped or ' #' in stripped:
                # Remove inline comment
                in_quotes = False
                comment_start = -1
                for j, char in enumerate(stripped):
                    if char == '"':
                        in_quotes = not in_quotes
                    elif not in_quotes and (char == ';' or char == '#'):
                        if j > 0 and stripped[j-1] == ' ':
                            comment_start = j
                            break
                if comment_start != -1:
                    stripped = stripped[:comment_start].rstrip()

            if not stripped:
                continue

            # Check if this line starts with whitespace (continuation)
            if line.lstrip() != line:
                # This is a continuation line
                if stripped.startswith('"') and stripped.endswith('"'):
                    current_value += '\n' + stripped[1:-1]
                else:
                    current_value += '\n' + stripped
            else:
                # Not a continuation line, break
                break

        return i, current_value

    # Main parsing loop
    while i < len(lines):
        line = lines[i]
        i += 1

        # Skip empty lines or comments
        if is_comment_or_empty(line):
            continue

        # Check for section header
        if line.strip().startswith('[') and line.strip().endswith(']'):
            section_name = line.strip()[1:-1].strip()
            if section_name == 'DEFAULT':
                current_section = None
                # We'll handle DEFAULT separately
                continue
            else:
                current_section = section_name
                if current_section not in result:
                    result[current_section] = {}
                continue

        # If we're not in a section, and not DEFAULT, this is invalid
        if current_section is None and not line.strip().startswith('['):
            raise ValueError("Key line before any section header")

        # Parse key-value
        try:
            key, value = parse_key_value(line, current_section)
        except ValueError as e:
            raise ValueError(f"Malformed line: {e}")

        # Handle continuation lines
        if i < len(lines):
            next_line = lines[i]
            if next_line.strip() and next_line.lstrip() != next_line:
                # Next line starts with whitespace -> continuation
                i, value = handle_continuation(lines, i, current_section, key, value)

        # Interpolate value
        try:
            value = interpolate_value(value, current_section, result)
        except KeyError as e:
            raise KeyError(str(e))
        except ValueError as e:
            raise ValueError(str(e))

        # Store in current section or DEFAULT
        if current_section is None:
            # This is a key in DEFAULT section
            default_section[key] = value
        else:
            # Store in current section, overwrite if key exists
            result[current_section][key] = value

    # Apply DEFAULT inheritance to all sections
    for section_name, section in result.items():
        for key, value in default_section.items():
            if key not in section:
                section[key] = value

    return result
```
Coding: TTL LRU (hard) Worked
```python
from collections import OrderedDict
from typing import Any, Optional, Dict

class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock):
        self.capacity = capacity
        self.ttl = ttl
        self.clock = clock
        self.data: OrderedDict[str, tuple[Any, float]] = OrderedDict()  # key -> (value, expiry_time)
        self.hits = 0
        self.misses = 0
        self.evictions = 0
        self.expirations = 0

    def _expire_old_entries(self):
        """Remove all expired entries from the cache."""
        now = self.clock()
        keys_to_remove = []
        for key, (value, expiry) in self.data.items():
            if now >= expiry:
                keys_to_remove.append(key)
        for key in keys_to_remove:
            del self.data[key]
            self.expirations += 1

    def _evict_lru(self):
        """Evict the least recently used entry if cache is at capacity."""
        if len(self.data) >= self.capacity and self.capacity > 0:
            self.data.popitem(last=False)
            self.evictions += 1

    def put(self, key: str, value: Any):
        """Insert or replace the value for the key."""
        if self.capacity == 0:
            return

        now = self.clock()
        expiry = now + self.ttl

        # Remove expired entries first
        self._expire_old_entries()

        # If key exists, update it (refresh expiry and recency)
        if key in self.data:
            self.data[key] = (value, expiry)
            self.data.move_to_end(key)  # mark as most recently used
        else:
            # If adding new key would exceed capacity, evict LRU if needed
            if len(self.data) >= self.capacity:
                self._evict_lru()

            # Add new key
            self.data[key] = (value, expiry)

    def get(self, key: str, default: Optional[Any] = None) -> Any:
        """Return value for key if present and not expired, else default."""
        if self.capacity == 0:
            self.misses += 1
            return default

        now = self.clock()

        # Check if key exists and is not expired
        if key in self.data:
            value, expiry = self.data[key]
            if now < expiry:
                # Hit: update recency and return value
                self.data.move_to_end(key)
                self.hits += 1
                return value
            else:
                # Expired: remove it and count as expiration and miss
                del self.data[key]
                self.expirations += 1
                self.misses += 1
                return default
        else:
            # Key not found
            self.misses += 1
            return default

    def __len__(self) -> int:
        """Return number of non-expired entries."""
        now = self.clock()
        return sum(1 for _, (value, expiry) in self.data.items() if now < expiry)

    def stats(self) -> Dict[str, int]:
        """Return statistics as a dictionary."""
        return {
            "hits": self.hits,
            "misses": self.misses,
            "evictions": self.evictions,
            "expirations": self.expirations
        }
```
Coding: Parse duration Didn't work
```python
import re

def parse_duration(s: str) -> int:
    s = s.strip()
    if not s:
        raise ValueError("Empty or blank string")
    
    # Split by whitespace and filter out empty strings
    parts = [p for p in s.split() if p]
    
    # Define allowed units (case-insensitive)
    units = {'h': 3600, 'm': 60, 's': 1}
    
    # Track which units we've already seen
    used_units = set()
    total_seconds = 0
    
    for part in parts:
        # Check if part ends with a valid unit
        if not part or not part[-1].isalpha():
            raise ValueError("Number without a unit")
        
        unit_char = part[-1].lower()
        if unit_char not in units:
            raise ValueError(f"Unknown unit: {unit_char}")
        
        # Extract the numeric part
        num_str = part[:-1]
        if not num_str.isdigit():
            raise ValueError("Decimal numbers not allowed")
        
        num = int(num_str)
        if num < 0:
            raise ValueError("Negative numbers not allowed")
        
        # Check if unit is already used
        if unit_char in used_units:
            raise ValueError(f"Repeated unit: {unit_char}")
        
        # Check order: h, then m, then s
        if used_units and not (used_units == {'h'} and unit_char == 'm' or 
                              used_units == {'h', 'm'} and unit_char == 's'):
            raise ValueError("Units must appear in order: h, m, s")
        
        # Add to total
        total_seconds += num * units[unit_char]
        used_units.add(unit_char)
    
    # Check if we have all required units in correct order
    # We don't require all units to be present, but if present, they must be in order
    # The order check above ensures this
    
    return total_seconds
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""
    
    # Remove duplicates and sort
    sorted_unique = sorted(set(nums))
    
    # Group consecutive numbers
    ranges = []
    start = sorted_unique[0]
    current = start
    
    for i in range(1, len(sorted_unique)):
        if sorted_unique[i] == current + 1:
            current = sorted_unique[i]
        else:
            # End of current run
            if current - start >= 2:  # 3 or more consecutive numbers
                ranges.append(f"{start}..{current}")
            else:
                # 1 or 2 numbers
                for j in range(start, current + 1):
                    ranges.append(str(j))
            start = sorted_unique[i]
            current = start
    
    # Handle the last run
    if current - start >= 2:
        ranges.append(f"{start}..{current}")
    else:
        for j in range(start, current + 1):
            ranges.append(str(j))
    
    return ",".join(ranges)
```
Coding: Top customers Didn't work
```python
import re
from typing import List, Dict, Tuple

def top_customers(rows: List[Dict], n: int) -> List[Tuple[str, float]]:
    customer_totals = {}
    
    for row in rows:
        customer = row.get("customer")
        amount_str = row.get("amount")
        
        # Skip if customer name is empty or cannot be parsed
        if not customer or not isinstance(customer, str):
            continue
        
        customer = customer.strip()
        if not customer:
            continue
        
        # Skip if amount is not a string or cannot be parsed
        if not isinstance(amount_str, str):
            continue
        
        # Clean amount string
        amount_str = amount_str.strip()
        if not amount_str:
            continue
        
        # Remove currency symbols and commas, handle negative amounts
        clean_amount = re.sub(r'[^\d.-]', '', amount_str)
        if not clean_amount or not re.match(r'^-?\d+(\.\d+)?$', clean_amount):
            continue
        
        try:
            amount = float(clean_amount)
        except ValueError:
            continue
        
        # Use the first valid customer name (trimmed) as the key
        if customer not in customer_totals:
            customer_totals[customer] = 0.0
        
        customer_totals[customer] += amount
    
    # Sort by total descending, then by name ascending
    result = sorted(customer_totals.items(), key=lambda x: (-x[1], x[0]))
    
    # Return top n customers
    return result[:n]
```
Coding: Slugify Didn't work
```python
import re
from unicodedata import normalize

def slugify(title: str, max_len: int = 50) -> str:
    # Normalize to NFD to separate accents, then remove non-ASCII characters
    normalized = normalize('NFD', title)
    ascii_only = ''.join(c for c in normalized if c.isascii() and (c.isalpha() or c.isdigit() or c.isspace()))
    
    # Lowercase
    lowercase = ascii_only.lower()
    
    # Replace non-alphanumeric (except spaces) with hyphens, then collapse multiple hyphens
    slug = re.sub(r'[^a-z0-9\s]+', '-', lowercase)
    slug = re.sub(r'\s+', ' ', slug)  # Replace multiple spaces with single space
    slug = re.sub(r'[^a-z0-9]+', '-', slug)  # Replace non-alphanumeric (except spaces) with hyphens
    slug = slug.strip('-')
    
    # If slug is longer than max_len, shorten without cutting words
    if len(slug) > max_len:
        # Split into words (separated by hyphens)
        words = slug.split('-')
        result_words = []
        current_length = 0
        
        for word in words:
            if current_length + len(word) + (1 if result_words else 0) <= max_len:
                result_words.append(word)
                current_length += len(word) + (1 if result_words else 0)
            else:
                break
        
        slug = '-'.join(result_words)
        
        # If first word is too long, hard-cut it
        if len(slug) > max_len:
            slug = slug[:max_len]
    
    # Ensure it doesn't end with a hyphen
    slug = slug.rstrip('-')
    
    return slug
```
Coding: Token bucket Worked
```python
import time

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock):
        self.capacity = capacity
        self.refill_per_sec = refill_per_sec
        self.clock = clock
        self._tokens = capacity
        self._last_refill_time = clock()

    def _refill(self):
        now = self.clock()
        elapsed = now - self._last_refill_time
        if elapsed > 0:
            refill_amount = elapsed * self.refill_per_sec
            self._tokens = min(self.capacity, self._tokens + refill_amount)
            self._last_refill_time = now

    def allow(self, cost: float = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

    @property
    def tokens(self) -> float:
        self._refill()
        return self._tokens
```
Decisions: Refund window (hard) 0% right
{"outcome": {"full_refund": 0.0, "partial_refund": 0.0, "store_credit": 0.0, "nothing": 0.0}, "defective": "false"}
Decisions: Incident matrix (hard) 0% right
{"severity": {"0": 0.0, "1": 0.0, "2": 0.7, "3": 0.3}, "page": {"true": 0.9, "false": 0.1}}
Decisions: Tool followup (hard) 0% right
{"tool": "calendar", "confirm": "true"}
Decisions: Legit security alert (hard) 100% right
{"phishing": {"true": 0.05, "false": 0.95}, "action_needed": {"true": 0.1, "false": 0.9}}
Decisions: Meeting slot (hard) 0% right
{"slot": {"A": 0.0, "B": 0.0, "C": 0.0, "D": 0.0}, "raj_last": "false"}
Decisions: Review mixed (hard) 100% right
{"hardware": {"true": 1.0, "false": 0.0}, "support": {"true": 1.0, "false": 0.0}}
Decisions: Support checkout down 100% right
{
  "department": {
    "technical": 0.95,
    "billing": 0.03,
    "account": 0.01,
    "shipping": 0.01,
    "sales": 0.00
  },
  "urgency": {
    "3": 0.98,
    "2": 0.02,
    "1": 0.00,
    "0": 0.00
  },
  "outage": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Refund wrong plan 100% right
{
  "department": {
    "billing": 0.95,
    "technical": 0.02,
    "account": 0.02,
    "shipping": 0.01,
    "sales": 0.0
  },
  "refund": {
    "true": 0.98,
    "false": 0.02
  },
  "tone": {
    "frustrated": 0.1,
    "calm": 0.9
  }
}
Decisions: Moderation doxxing 100% right
{
  "policy": {
    "none": 0.0,
    "harassment": 0.95,
    "hate": 0.03,
    "spam": 0.01,
    "self_harm": 0.01
  },
  "personal_info": {
    "true": 0.98,
    "false": 0.02
  }
}
Decisions: Route calendar 100% right
{
  "tool": {
    "calendar": 0.95,
    "email": 0.03,
    "none": 0.01,
    "web_search": 0.01,
    "calculator": 0.0
  },
  "confirm": {
    "true": 0.9,
    "false": 0.1
  }
}
Decisions: Doc invoice missing due 100% right
{
  "doc_type": {
    "invoice": 0.95,
    "resume": 0.01,
    "contract": 0.02,
    "bank_statement": 0.01,
    "other": 0.01
  },
  "missing_due_date": {
    "true": 0.98,
    "false": 0.02
  }
}
Decisions: Phishing paypal 100% right
{
  "phishing": {
    "true": 0.98,
    "false": 0.02
  },
  "risk": {
    "0": 0.01,
    "1": 0.01,
    "2": 0.02,
    "3": 0.96
  }
}
Decisions: Pii ssn email 100% right
{
  "data_kind": {
    "none": 0.0,
    "contact": 0.1,
    "financial": 0.0,
    "government_id": 0.9,
    "health": 0.0
  },
  "sensitive": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Review mixed 100% right
{
  "sentiment": {
    "positive": 0.05,
    "neutral": 0.1,
    "negative": 0.85
  },
  "defect": {
    "true": 0.95,
    "false": 0.05
  },
  "recommend": {
    "true": 0.05,
    "false": 0.95
  }
}
Documents: Saas escalator (hard) 30% right
{
  "year2_price_per_seat_month": 47.37,
  "year3_price_per_seat_month": 49.82,
  "year1_invoice": 64800.0,
  "year2_invoice": 66820.8,
  "addon_months_billed": 6,
  "addon_invoice": 39856.0,
  "year3_invoice": 127577.6,
  "year3_discount_percent": 15,
  "total_contract_value": 299254.4,
  "contract_end_date": "2027-02-28"
}
Documents: Expense thread 83% right
{"employee_id": "EMP-20417", "destination_city": "Lisbon", "trip_start": "2025-02-24", "trip_end": "2025-02-27", "approved_items": [{"date": "2025-02-24", "category": "airfare", "amount_usd": 1184.60}, {"date": "2025-02-24", "category": "ground_transport", "amount_usd": 38.88}, {"date": "2025-02-25", "category": "meals", "amount_usd": 229.40}, {"date": "2025-02-26", "category": "lodging", "amount_usd": 466.56}, {"date": "2025-02-27", "category": "ground_transport", "amount_usd": 44.82}], "rejected_item_count": 1, "per_diem_days": 1, "per_diem_usd": 65.0, "total_reimbursable_usd": 2489.26, "approver_email": "priya.raman@corvane.com"}
Documents: Lease amendment 92% right
{"tenants": ["Marcus Lin", "Sofia Lin"], "landlord": "Ridgeline Property Group LLC", "zip": "97205", "lease_end": "2025-11-30", "original_monthly_rent": 2150.0, "monthly_rent_from_2025_06_01": 2236.0, "late_fee_from_2025_06_01": 111.8, "security_deposit": 2150.0, "total_pet_deposits": 800.0, "total_monthly_payment_july_2025": 2306.0, "move_in_payment": 5700.0}
Documents: Ticket SLA 82% right
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": ["billing_address"],
  "affected_orders": ["SO-99812", "SO-99820", "SO-99827"],
  "priority": "P2",
  "sla_due_local": "2025-09-14T15:30",
  "sla_due_utc": "2025-09-14T20:30:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 44% right
{
  "q3_total_usd": 15346,
  "q2_total_usd": 14464,
  "q2_central_originally_reported_usd": 3047,
  "q2_to_q3_change_pct": 6.1,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": ["East"],
  "international_q3_organic_usd": 1731,
  "west_excluding_mountain_q3_usd": 4201
}

Size: 31B parameters. First tested OCT 10.

Models that scored about the same

Comments

Sign in with GitHub to comment. Spam and abuse are hidden automatically.