GPU Chooser

This workbook can help rank GPUs according a mixture of features (with the weights determined by the user) and graph it against price.

However, Amazon has now put a price-grabber-blocker in place, so it probably makes sense to switch to NewEgg - or a price grabbing service (that would keep their IDs consistent over time by default).

Quick Fix

If this fails when downloading the price data, make sure that you have installed Python's Beautiful Soup package (for HTML parsing) : pip install beautifulsoup4.

Data

Firstly, pull in the parameters from Wikipedia (NVidia cards, AMD cards) for the cards under consideration (more can easily be added, though, to keep the list reasonable, let's ignore cards with <1000 single precision GFLOPs), each with one example of the product on Amazon or NewEgg (more Amazon/NewEgg examples can be added below) :


In [1]:
raw="""
name                  |   sh:tx:rop |   mem | bw  | bus | ocl |single|double|watts| passmark
GeForce GT 740        |  384:32:16  |  4096 |  28 | 128 | 1.2 |  763 |   0 |  65 | 1579

GeForce GTX 750       |  512:32:16  |  2048 |  80 | 128 | 1.2 | 1044 |  32 |  55 | 3271
GeForce GTX 750 Ti    |  640:40:16  |  4096 |  80 | 128 | 1.2 | 1306 |  40 |  60 | 3695
GeForce GTX 760       | 1152:96:32  |  4096 | 192 | 256 | 1.2 | 2257 |  94 | 170 | 4952    
GeForce GTX 760 Ti    | 1152:96:32  |  4096 | 192 | 256 | 1.2 | 2257 |  94 | 170 | 5059    

GeForce GTX 960       | 1024:64:32  |  4096 | 112 | 128 | 1.2 | 2308 |  72 | 120 | 5828
GeForce GTX 970       | 1664:104:56 |  3584 | 196 | 224 | 1.2 | 3494 | 109 | 145 | 8573
GeForce GTX 980       | 2048:128:64 |  4096 | 224 | 256 | 1.2 | 4612 | 144 | 165 | 9592
GeForce GTX 980 Ti    | 2816:176:96 |  6144 | 336 | 384 | 1.2 | 5632 | 176 | 250 |11350     
GeForce GTX Titan X   | 3072:192:96 | 12288 | 336 | 384 | 1.2 | 6144 | 192 | 250 |10669     

GeForce GTX 1060 3GB  | 1152:72:48  |  3072 | 192 | 192 | 1.2 | 3470 | 108 | 120 | 8567
GeForce GTX 1060 6GB  | 1280:80:48  |  6144 | 192 | 192 | 1.2 | 3855 | 120 | 120 | 8662
GeForce GTX 1070      | 1920:120:64 |  8192 | 256 | 256 | 1.2 | 5783 | 181 | 150 |10906 
GeForce GTX 1080      | 2560:160:64 |  8192 | 320 | 256 | 1.2 | 8228 | 257 | 180 |11982
GeForce GTX 1080 Ti   | 3584:224:88 | 11264 | 484 | 352 | 1.2 |10609 | 332 | 250 |13247     
NVIDIA TITAN Xp       | 3584:224:96 | 12288 | 480 | 384 | 1.2 |10157 | 317 | 250 |14894

"""

sadly_waiting_on_decent_drivers = """
Radeon HD 5570        |  400:20:8   |  1024 |  29 | 128 | 1.2 |  520 |   0 |  39 |  712
Radeon R9 280         | 1792:112:32 |  3072 | 240 | 384 | 1.2 | 2964 | 741 | 250 | 5283
Radeon R9 290 / 390   | 2560:160:64 |  4096 | 320 | 512 | 2   | 4848 | 606 | 275 | 7026
Radeon R9 290X / 390X | 2816:176:64 |  4096 | 320 | 512 | 2   | 5632 | 704 | 290 | 7306

Radeon R9 390X        | 2816:176:64 |  8192 | 384 | 512 | 2.1 | 5914 | 739 | 275 | 8431
Radeon RX 480
"""

import re
arr = [ re.split(r'\s*[|:]\s*',l) for l in raw.split('\n') if len(l)>0]
headings = arr[0]
#print(headings)
cards={ a[0]:{ h:(e if h in ':name:' else float(e)) for h,e in zip(headings,a) } for a in arr[1:] }
# Create a place for the pricing to go (with the PassMark entry there for starters)
for c in cards.keys(): cards[c]['pricing']={c:dict(px=None,brand='PassMark',comment='')}

Now the GPU card data is in a nice array of dictionary entries, with numeric entries for all but 'name' which matches the corresponding entry on the GPU Passmarks benchmarks list.

Add sku information required for gathering Price data

Here, one can put additional Amazon product codes that refer to the same card from a Compute perspective (different manufacturer and/or different ports may make the cards different from a gaming user's perspective, of course).

TODO : Add in more prices, to get a broader sample


In [2]:
#pricing={ a['sku']:{k:v for k,v in a.items() if k in 'name:brand:comment:sku:pm'} for a in cards}
#for c in cards:print("%s|%s" % (c['name'], c['amz']))

In [3]:
raw="""
name                  |sku:brand:comment   
GeForce GT 740        | B00KJGYOBQ
GeForce GTX 750       | B00J3ZNB04
GeForce GTX 750 Ti    | B00T4RJ8FI
GeForce GTX 760       | B00E9O28DU
GeForce GTX 960       | B00UOYQ5LA
GeForce GTX 970       | B00NVODXR4
GeForce GTX 970       | B00NH5ZNWA:PNY
GeForce GTX 970       | B00OQUMGM0:GigabyteMiniITX
GeForce GTX 980       | B00NT9UT3M
GeForce GTX 980 Ti    | B00YNEIAWY
GeForce GTX 980 Ti    | B00YDAYOK0:EVGA
GeForce GTX Titan X   | B00UXTN5P0
GeForce GTX 1060 3GB  | B01KUADE3O
GeForce GTX 1060 6GB  | B01IPVSGEC
GeForce GTX 1070      | B01GLRX81I
GeForce GTX 1080      | B01IR6LMLO
GeForce GTX 1080 Ti   | B06XH2P8DD
NVIDIA TITAN Xp       | B01JLKP3IS
"""

sadly_waiting_for_decent_drivers = """
HD 5570 1Gb           | B004JU260O
R9 280                | B00IZXOW80
R9 290                | B00V4JVY1A
R9 290X               | B00FLMKQY2
R9 380 2Gb            | B00ZGL8EBK
R9 380 4Gb            | B00ZGF3TUC
R9 390 8Gb            | B00ZGL8CYY
R9 390 8Gb            | B00ZGF0UAE:MSI
R9 390 8Gb            | B00ZGF3UAQ:Gigabyte
R9 390 8Gb            | B00ZGL8CYY:Sapphire
R9 390 8Gb            | B00ZQ3QVU4:Asus
R9 390 8Gb            | B00ZQ9JKSS:Visiontech
R9 390X               | B00ZGL8CFI
R9 390X               | B00ZGF158A:MSI
R9 390X               | B00ZGF3TNO:Gigabyte
R9 390X               | B00ZGL8CFI:Sapphire
"""
arr = [ re.split(r'\s*[|:]\s*',l) for l in raw.split('\n') if len(l)>0]
headings = arr[0]
for a in arr[1:]:
    data={ h:e for h,e in zip(headings,a) }
    if a[0] in cards:
        cards[a[0]]['pricing'][data['sku']]=data
    else:
        print("Card named '%s' not found in core listing" % (a[0],))
#pricing.update({ a['sku']:a for a in equivs if a['sku'] })
#pricing

Add known prices from Cache

If you want to regenerate these, execute the block below. To 'cache' them back into this script, simply copy the generated list back into the following cell


In [4]:
cache={'B00UXTN5P0': 737.00, 'B00IDG3NDY': 114.12, 'B00DT5R3EO': 199.99, 'B004JU260O': 180.99, 'B00ZGL8EBK': 216.53, 'B01IPVSGEC': 230.39, 'B00ZGF158A': 429.99, 'B00NVODXR4': 337.99, 'B00UOYQ5LA': 239.99, 'B00ZQ3QVU4': 349.99, 'B00KJGYOBQ': 99.99, 'B01JLKP3IS': 1499.99, 'B00YNEIAWY': 698.85, 'B00OQUMGM0': 299.99, 'B06XH2P8DD': 699.99, 'B00IDG3IDO': 139.99, 'B00ZGF3UAQ': 329.99, 'B00J3ZNB04': 149.37, 'B00FLMKQY2': 339.99, 'B01KUADE3O': 189.99, 'B00SC6HAS4': 199.99, 'B00ZGF3TUC': 229.99, 'B00ZGF0UAE': 369.99, 'B00ZGL8CFI': 458.63, 'B01IR6LMLO': 469.99, 'B00T4RJ8FI': 349.99, 'B00ZQ9JKSS': 368.63, 'B00ZGL8CYY': 359.42, 'B00V4JVY1A': 333.26, 'B00IZXOW80': 249.99, 'B00YDAYOK0': 679.99, 'B00E9O28DU': 274.99, 'B00NT9UT3M': 507.82, 'B00ZGF3TNO': 499.99}
cache.update(
    {'B00ZQ3QVU4': 349.99, 'B01JLKP3IS': 1499.99, 'B01GX5YWAO': 399.99, 'B00DT5R3EO': 199.99, 'B00UOYQ5LA': 239.99, 'B01KUADE3O': 189.99, 'B00V4JVY1A': 333.26, 'B00FLMKQY2': 366.08, 'B00J3ZNB04': 149.37, 'B00ZGF3UAQ': 361.79, 'B00IDG3IDO': 139.99, 'B06XH2P8DD': 699.99, 'B00KJGYOBQ': 99.99, 'B004JU260O': 159.99, 'B01IPVSGEC': 230.39, 'B00IDG3NDY': 114.12, 'B00YNEIAWY': 698.85, 'B00SC6HAS4': 199.99, 'B01IR6LMLO': 469.99, 'B00T4RJ8FI': 349.99, 'B00ZGF0UAE': 399.99, 'B00OQUMGM0': 329.0, 'B00YDAYOK0': 679.99, 'B00ZGL8EBK': 216.53, 'B00ZGL8CYY': 359.42, 'B00NT9UT3M': 507.82, 'B00ZGF158A': 429.99, 'B00IZXOW80': 249.99, 'B00ZQ9JKSS': 368.63, 'B01GLRX81I': 379.89, 'B00NVODXR4': 337.99, 'B00UXTN5P0': 737.0, 'B00ZGF3TUC': 229.99, 'B00NH5ZNWA': 329.99, 'B00ZGL8CFI': 458.63, 'B00E9O28DU': 274.99, 'B00ZGF3TNO': 499.99}
)
#for k,v in cache.items():
#    #if k in pricing and pricing[k].get('px',None) is None:
#    if k in pricing and (pricing[k].get('px',None) is None or pricing[k]['px']<v):
#        pricing[k]['px'] = v
for name,card_data in cards.items():
    for sku,sku_data in card_data['pricing'].items():
        if sku in cache:
            v = cache[sku]
            if sku_data.get('px',None) is None or sku_data['px']<v:
                sku_data['px'] = v
#pricing

Price Grabbing

The price downloading/parsing requires that you have requests and BeautifulSoup installed : pip install requests BeautifulSoup4


In [ ]:
import requests
from bs4 import BeautifulSoup
import re

Grab prices from Amazon

Rather than use their API (which creates the issue of putting the keys into GitHub), just grab the pages. NB: The page caches the prices found into the data structure to avoid doing this too often!

EXCEPT : Sometimes this just brings back a 'discuss API pricing' page. Bummer


In [ ]:
BASE_URL = "http://www.amazon.com/exec/obidos/ASIN/"

for name,card_data in cards.items():
    for sku,sku_data in card_data['pricing'].items():
        if not sku.startswith('B0'): continue  # Skip non-Amazon SKUs
        if sku_data.get('px', None) is None:
        #if True:
            print("Fetching price '%s' for %s from Amazon.com" % (sku, name))
            r = requests.get(BASE_URL + sku)
            print("  Got %d bytes" % (len(r.text),))
            soup = BeautifulSoup(r.text, 'html.parser')
            price = None
            try:
                ele = soup.find(id="priceblock_ourprice")
                price = float(ele.text.replace('$','').replace(',',''))
            except AttributeError:
                print("    Didn't find the 'price' element for %s (%s)" % (name, sku))
            sku_data['px']=price

print("Finished downloading Amazon prices : Run the 'cache' script below to save the data")

In [ ]:
#r.text

Grab from Passmark GPU benchmarks


In [ ]:
BASE_URL = "http://www.videocardbenchmark.net/gpu_list.php"
headers = { 'User-Agent': 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0', }
r = requests.get(BASE_URL, headers=headers )
print("  Got %d bytes" % (len(r.text),))
#r.text

In [ ]:
soup = BeautifulSoup(r.text, 'html.parser')

try:
    trs = soup.find(id='cputable').find_all('tr', id=True)
    print("Found %d cards" % (len(trs),))
    for tr in trs:
        tds = tr.find_all('td')
        card = tds[0].a.text
        if card in cards:
            px_str = tds[-1].text
            try:
                px = float( re.sub(r'[\$\*\,]', '', px_str))
                cards[card]['pricing'][card]['px'] = px
                print("%s\t%10s\t%.2f" % ((card+' '*20)[:20], px_str, px))
            except ValueError:
                print("    Couldn't parse the 'price' element for %s (%s)" % (card, px_str))
except AttributeError:
    print("    Didn't find the 'pricing table' element")

In [ ]:

Code required to 'cache' prices found

Exectute the following, and copy its output to the pxs= line above so that the page can remember the prices found most recently.


In [ ]:
# TODO print({ k:v['px'] for k,v in pricing.items() if v.get('px',None) is not None})

Aggregate Prices (to determine range, and minimum per card)


In [5]:
for name, data in cards.items():
    pxs = [ sku_data['px'] for sku,sku_data in data['pricing'].items() if sku_data.get('px', None) is not None ]
    if len(pxs)>0:
        data['px_min']=min(pxs)
        data['px_max']=max(pxs)

Show Known Prices


In [6]:
for name, data in sorted(cards.items()):
    for sku, sku_data in data['pricing'].items():
        if sku_data.get('px', None) is not None:
            print("%s| $%7.2f" % ((name+' '*30)[:24], sku_data['px']))

Weights for Different Card Features

The concept here is that one can focus on a 'basecard' (for instance, one you already have, or one you've looked at closely), and then assign multiplicative weights to each of a GPU card's qualities, and come up with a 'relative performance' according to that weighting scheme.


In [14]:
# FLOPs are twice as important as memory, all else ignored
multipliers = dict(single=2., mem=1.)  

# ignore cards with OpenCL>3 (none), or prices above 1500
card_names_filtered = [name for name,data in cards.items() if data['ocl']<3. and data.get('px_min',1000000)<1000. ]

Score Cards based on given weights


In [15]:
basecard = 'GeForce GTX 980' # Name should match a card with full data above
basedata = [ data for name, data in cards.items() if name==basecard ][0]  

def evaluate_card(base, data, mult):
    comp=0.
    for (k,v) in mult.items():
        if data.get(k,None) is not None and base.get(k,None) is not None:
            comp += v*data[k]/base[k]
    return comp
x=[ cards[name].get('px_min',None) for name in card_names_filtered ]
y=[ evaluate_card(basedata, cards[name], multipliers) for name in card_names_filtered ]
l=[ name for name in card_names_filtered ]
for name,score,px in sorted(zip(l,y,x), key=lambda p: -p[1]):
    print("%s| $%7.2f | %5.2f" % ((name+' '*30)[:24], px, score))

Visualize the Results

Finally, the card scores can be visualised, against their absolute dollar cost (the 'efficient frontier' being the envelope around the points from the upper left corner).


In [16]:
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np

plt.figure(figsize=(15, 8))
plt.plot(x,y, 'ro')
plt.xlabel('Price', fontsize=16)
plt.ylabel('Score', fontsize=16)
#print( dir( plt.axes().get_xlim ))
for i, xy in enumerate(zip(x, y)): 
    plt.annotate('%s' % (l[i]), xy=xy, xytext=(5,.05), textcoords='offset points')
    start, stop = plt.axes().get_xlim()
    #plt.axes().set_xticks(np.arange(start, stop + 100., 100.))  # Force $100 units on x-axis 
    plt.axes().set_xticks(np.arange(0, 1000., 100.))  # Force $100 units on x-axis 
    plt.axes().set_yticks(np.arange(0, 8., 0.5))  # Force $100 units on x-axis 
    plt.grid(b=True, which='major', color='b', axis='x', linestyle='-')
    plt.grid(b=True, which='major', color='b', axis='y', linestyle='--')
plt.show()

In [ ]: